Урок курса
Один ключ для всех попыток создания
Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и DockerLabelOperation как единственная идентичность запроса: сборка и отправка
В уроке 6.1 клиент сохранял LabelOperation с полями RequestID и ParcelID до отправки любого запроса. Здесь тот же объект становится источником всех данных при каждой попытке — и тела, и заголовка Idempotency-Key. Это осознанный контракт: у намерения одна идентичность в JSON и в lookup-ключе одновременно.
RequestID — это идентификатор операции, не посылки. ParcelID обозначает объект, над которым совершается действие. Два поля независимы: разные RequestID при одинаковом ParcelID означают разные операции, а не повтор одной. Сервер не налагает уникальности этикетки на уровне посылки — это отдельное бизнес-правило, которое здесь не реализуется.
В этой задаче предоставленный тип имеет JSON-теги, задающие точные имена полей тела. Прежний тип без тегов сериализовался бы с именами RequestID и ParcelID; здесь это дополнение вводится явно:
type LabelOperation struct {
RequestID string `json:"request_id"`
ParcelID string `json:"parcel_id"`
}
Клиент собирает запрос из сохранённой op так. Фрагмент иллюстративный, компиляция здесь не проверялась:
import (
"bytes"
"context"
"encoding/json"
"net/http"
)
func buildRequest(ctx context.Context, op LabelOperation) (*http.Request, error) {
body, err := json.Marshal(op)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.com/labels", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", op.RequestID)
return req, nil
}
При каждой повторной попытке вызов buildRequest с той же op даёт тот же заголовок Idempotency-Key и то же тело — потому что op.RequestID не меняется между попытками. Именно это и является контрактом: ключ привязан к намерению, а не к факту доставки.
Здесь нет цикла retry, backoff и бюджета попыток — это тема следующих уроков. Фрагмент выше показывает только сборку одного запроса из сохранённой операции.
Важно понять ограничение: сам по себе заголовок Idempotency-Key ничего не гарантирует. Если реплики сервера хранят состояние независимо друг от друга, один и тот же ключ может прийти к разным экземплярам и каждый создаст этикетку. Повторяемость обеспечивает только общий устойчивый реестр с атомарным занятием ключа. Заголовок — лишь носитель ключа, а не механизм дедупликации.
Если клиент по ошибке создаёт новую op с новым RequestID для той же посылки, сервер расценивает это как отдельную операцию и может создать вторую этикетку. Контракт не защищает от этого: ответственность за стабильность ключа лежит на клиенте, и именно для этого 6.1 требует сохранять op до первой отправки.
Валидация полей и вычисление fingerprint через SHA-256
Прежде чем обратиться к store, обработчик обязан проверить запрос полностью: путь, метод, Content-Type, размер тела, структуру JSON, допустимость значений и совпадение заголовка с телом. Только запрос, прошедший все эти проверки, доходит до Claim.
Порядок проверок фиксирован: путь → метод → тип содержимого → размер тела до декодирования → JSON, поля и ключ → Claim. GET /other получает 404, даже если другие части запроса неверны.
| Проверка | Ответ при нарушении |
|---|---|
| Путь ровно /labels | 404, {"error":"not_found"} |
| Метод POST | 405, Allow: POST, {"error":"method_not_allowed"} |
| Content-Type application/json, параметры допустимы | 415, {"error":"unsupported_media_type"} |
| Тело не больше 4096 байт | 413, {"error":"body_too_large"} |
| JSON, поля и ключ | 400, {"error":"invalid_request"} |
Каждый ответ имеет Content-Type: application/json и точные указанные байты тела без завершающей новой строки. Все ветки отказа до Claim обходятся без store и callbacks. mime.ParseMediaType позволяет принять application/json; charset=utf-8. Размер можно проверить чтением максимум 4097 байт до декодирования: дополнительный байт обнаруживает превышение; слишком большое невалидное тело также получает 413. Фрагменты здесь иллюстративные и не компилировались.
Допустимый request_id и parcel_id — непустые строки из символов [A-Za-z0-9_-]. Отсутствующее поле, null, число, булево значение, пустая строка или символ вне алфавита — всё это 400 {"error":"invalid_request"}. Объект должен содержать ровно два поля с точными именами; лишнее поле или второй JSON-объект после первого — тоже невалидный запрос. Объекты с повторяющимися именами полей или с именами, отличающимися от request_id/parcel_id только регистром, исключены из входов задачи: их обработка не входит в условия и оценивание задачи.
Заголовок Idempotency-Key обязан присутствовать и быть равен request_id из тела. Несовпадение — 400, потому что ключ и тело описывают разные намерения, а это невалидный запрос на уровне протокола.
После успешной валидации вычисляется fingerprint:
import (
"crypto/sha256"
"encoding/hex"
)
func fingerprint(requestID, parcelID string) string {
h := sha256.Sum256([]byte(requestID + "\n" + parcelID))
return hex.EncodeToString(h[:])
}
sha256.Sum256 возвращает [32]byte; h[:] превращает массив в срез для hex.EncodeToString. Результат — 64 символа нижнего регистра. Для request_id="label-req-7" и parcel_id="parcel-42" функция даст один и тот же хеш независимо от того, было ли тело {"request_id":"label-req-7","parcel_id":"parcel-42"} или {"parcel_id":"parcel-42", "request_id" : "label-req-7"} — потому что fingerprint считается от нормализованных значений полей, а не от сырых байт JSON. Порядок полей и пробелы в теле не влияют на хеш.
Разделитель "\n" однозначен: валидированные поля не содержат символа новой строки, поэтому конкатенация "ab\ncd" и "a\nb\ncd" невозможна при проверенных входах.
Fingerprint — средство сравнения, не криптографическая подпись. Он позволяет сказать «это то же намерение» или «намерение изменилось», но не доказывает подлинность клиента. Если при существующем ключе fingerprint не совпадает — store возвращает ClaimConflict ещё до проверки статуса записи (pending или completed). Это значит: попытка переиспользовать ключ для другой посылки блокируется немедленно, без шанса «перезаписать» операцию.
Проверка валидности через регулярное выражение или посимвольный цикл — выбор реализации; важно, что проверка происходит до обращения к store и до вычисления fingerprint не нужно тратить время на невалидные входы.
Атомарное занятие ключа: четыре ветки Claim и воспроизведение сохранённого ответа
Claim(ctx, key, fingerprint) — единственная точка входа в реестр. Она атомарно смотрит, существует ли запись с данным ключом, и принимает решение, не допуская гонки между двумя параллельными запросами с одним ключом.
Логика проверки внутри store устроена так: сначала сравнивается fingerprint, и только при совпадении — статус записи. Несовпадение fingerprint при любом статусе (pending или completed) возвращает ClaimConflict. Совпадающий fingerprint при pending-записи — ClaimPending. Совпадающий fingerprint при completed-записи — ClaimReplay с сохранённым ответом. Отсутствие ключа — ClaimNew с созданием pending-резервации.
В обработчике ctx берётся из r.Context() и передаётся в Claim, Admit, Create, Complete и Release без создания context.Background(). Ниже иллюстративный фрагмент после валидации; компиляция здесь не проверялась:

ctx := r.Context()
cr := store.Claim(ctx, op.RequestID, fingerprint(op.RequestID, op.ParcelID))
switch cr.Kind {
case ClaimNew:
// admission + create + complete + write
case ClaimPending:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(409)
w.Write([]byte(`{"error":"request_pending"}`))
case ClaimReplay:
saved := cr.Response
for k, vals := range saved.Header {
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(saved.Status)
w.Write(saved.Body)
case ClaimConflict:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(409)
w.Write([]byte(`{"error":"idempotency_conflict"}`))
}
ClaimPending и ClaimConflict возвращают 409, но с разными телами. request_pending — принятая незавершённая операция, не отказ действия; повтор не получает права выполнить Create. Pending означает резервацию или незавершённую операцию, а не доказательство текущей работы: ключ занимается до Admit и может остаться занятым после падения. idempotency_conflict — попытка переиспользовать ключ для другого намерения; store ничего не меняет.
Replay принципиально отличается от обоих: это не ошибка, а повторная доставка уже сохранённого результата. Обработчик не вызывает Admit, Create, Complete или Release — только записывает в ResponseWriter то, что store уже хранит.
Запись заголовков через Header.Add в цикле по значениям критична: предоставленный учебный ответ содержит несколько значений ETag. Каждый Header.Set заменяет существующий список, поэтому цикл оставит только последнее значение. Это условие учебного примера, а не общее правило о многозначном ETag в реальном HTTP. Сохранённый Header принадлежит store — обработчик не должен его модифицировать. При вызове Complete store делает глубокую копию Response, включая []byte тела и http.Header, чтобы последующая запись в ResponseWriter не испортила сохранённое состояние.
Пример того, как должен выглядеть сохранённый Response от Create:
Status: 201
Header: Content-Type: application/json
Location: /labels/label-901
ETag: "label-901-v1"
Body: {"request_id":"label-req-7","state":"created","label_id":"label-901"}
При replay клиент получает точно те же байты тела, тот же статус и все значения заголовков — без нового вызова Create и без нового label_id. Это и есть смысл idempotency: сервер отвечает «я уже сделал это» вместо «делаю ещё раз».
Реальный реестр должен быть общим для всех реплик сервиса и устойчивым к рестарту. Локальная map с mutex не даёт ни того, ни другого: после рестарта pending-записи исчезают, а параллельные реплики не видят друг друга. Реализация атомарности и хранения — за пределами задачи; store предоставляется готовым.
В задаче один владелец и один endpoint. В production пространство ключей должно включать владельца или tenant и вид операции, иначе чужой ключ может раскрыть сохранённый ответ. Атомарное владение даёт право на Create только новой владеющей попытке; повторы этого права не получают.
Pre-admission отказ, Release и разрыв между Create и Complete
При ClaimNew обработчик вызывает Admit(ctx). Это единственное место, где admission имеет смысл: pending-резервация уже создана, ключ занят, и нужно решить — начинать ли реальную работу. Admit по условиям возвращает только 0, 429 или 503; Create возвращает готовый корректный Response со статусом 201, а методы store не могут завершиться ошибкой. Это гарантии задачи; production требует отдельной обработки ошибок зависимостей. Контекст всех вызовов — ctx := r.Context() из предыдущего раздела. Фрагменты иллюстративные и здесь не компилировались.
429 и 503 здесь означают конкретное: admission-сервис явно отказал до начала бизнес-операции. В этом случае Create не вызывался, бизнес-изменений нет, и pending-резервацию нужно освободить через Release — иначе ключ навсегда останется в состоянии pending и следующий запрос получит request_pending вместо нового шанса. После Release обработчик возвращает тот же статус, что вернул Admit, с заголовком Retry-After: 2 и телом {"error":"not_admitted"}:
case ClaimNew:
code := admit(ctx)
if code == 429 || code == 503 {
store.Release(ctx, op.RequestID)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "2")
w.WriteHeader(code)
w.Write([]byte(`{"error":"not_admitted"}`))
return
}
resp := create(ctx, op)
store.Complete(ctx, op.RequestID, resp)
for k, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.Status)
w.Write(resp.Body)
После Release запись исчезает. Следующий запрос с тем же ключом попадёт в ClaimNew снова и сделает единственный Create при успешном admission. Это можно проверить последовательными запросами через httptest. Фрагмент использует предоставленные fake-store и журнал. newRig собирает обработчик; request создаёт запрос и recorder и вызывает ServeHTTP. Последний аргумент задаёт ожидаемый журнал: helper завершает проверку ошибкой при несовпадении. exact проверяет статус, точное тело и все значения перечисленных заголовков; require завершает проверку ошибкой при ложном условии. Это иллюстрация, не результат запуска.
x := newRig()
op := LabelOperation{RequestID: "label-req-7", ParcelID: "parcel-42"}
body := `{"request_id":"label-req-7","parcel_id":"parcel-42"}`
x.admission = 429
r := x.request("POST", "/labels", op.RequestID, "application/json", body,
[]string{"claim", "admit", "release", "write"})
exact(r, 429, `{"error":"not_admitted"}`, http.Header{
"Content-Type": {"application/json"}, "Retry-After": {"2"},
})
require(x.creates == 0 && len(x.store.records) == 0,
"refusal must release without Create")
x.admission = 0
r = x.request("POST", "/labels", op.RequestID, "application/json", body,
[]string{"claim", "admit", "create", "complete", "write"})
exact(r, 201, string(x.completesResponse.Body), x.completesResponse.Header)
require(x.creates == 1 && len(x.store.records) == 1 &&
x.store.records[op.RequestID].done, "one completed creation")
Две трассы требуют двух Claim и двух Admit, одного Release, одного Create и одного Complete. write отмечает первую запись статуса или тела, поэтому журналы проверяют Release и Complete до ответа. Для 503 нужен аналогичный отдельный сценарий с точным статусом 503 на первой попытке.
Поэтому Release разрешён только при подтверждённом pre-admission отказе — то есть когда Admit вернул 429 или 503. Произвольный сетевой 503, клиентский таймаут или ошибка внутри Create не дают права освобождать ключ. Принятая незавершённая операция остаётся pending, и клиент продолжает получать request_pending при повторах — это предотвращает повторный Create, но не доказывает, что работа продолжает выполняться.
Между Create и Complete существует разрыв: после создания этикетки процесс может упасть до сохранения ответа и оставить pending. Recovery и expiry выходят за рамки задачи. Exactly-once не обещается: не более одного Create обеспечивается атомарным владением ключом, пока резервация или сохранённая запись удерживается. Произвольное истечение срока pending с освобождением ключа может разрешить второй Create после уже выполненного первого. Production требует атомарного сохранения бизнес-изменения вместе с результатом либо восстанавливаемого протокола.
Клиент продолжает lookup из 6.1 по тому же request_id. GET может обнаружить записанный результат, но после падения между Create и Complete тоже может показывать processing. Чтение само по себе не разрешает зависшую резервацию; GET здесь не реализуется.
Replay проверяем третьим запросом. В предоставленном учебном ответе два значения ETag; exact сравнивает все сохранённые значения и точные байты тела. Проверка ниже также делает ожидание заголовка явным:
r = x.request("POST", "/labels", op.RequestID, "application/json", body,
[]string{"claim", "write"})
exact(r, 201, string(x.completesResponse.Body), x.completesResponse.Header)
require(reflect.DeepEqual(r.Result().Header.Values("ETag"),
[]string{`"label-901-v1"`, `"backup"`}), "both ETag values")
require(x.creates == 1 && len(x.store.records) == 1 &&
x.store.records[op.RequestID].done, "replay preserves completed state")
Журнал replay допускает только Claim и запись: callbacks и Complete/Release не вызываются. Общий счётчик создания остаётся равным единице; JSON для ответа заново не собирается.
Complete вызывается до записи ответа клиенту. Это единственный корректный порядок: если запись в store произошла, клиент получит сохранённый результат при следующем повторе, даже если соединение оборвалось сразу после WriteHeader. Обратный порядок — сначала ответ клиенту, потом Complete — создаёт окно, в котором повтор попадает в ClaimPending вместо ClaimReplay.
Задача 1
Клиент отправил POST /labels с Idempotency-Key: label-req-7 и телом {"request_id":"label-req-7","parcel_id":"parcel-42"}. Сервер принял запрос: вызвал Admit (вернул 0), затем Create, затем Complete — и сохранил ответ 201 с заголовками Content-Type, Location и ETag. Ответ не дошёл до клиента (соединение оборвалось). Клиент повторяет точно тот же запрос с тем же ключом и тем же телом. Что должен вернуть сервер при повторе?
Задача 2
В реестре уже есть completed-запись для ключа label-req-7, созданная запросом с parcel_id="parcel-42". Приходят два новых запроса с тем же ключом. Запрос A: тело {"parcel_id":"parcel-42", "request_id" : "label-req-7"} (другой порядок полей и пробелы). Запрос B: тело {"request_id":"label-req-7","parcel_id":"parcel-99"} (другой parcel_id). Какой результат возвращает сервер для каждого запроса?
Задача 3
Клиент отправляет POST /labels с ключом label-req-7 и валидным телом. Claim возвращает ClaimNew и создаёт pending-резервацию. Admit возвращает 429. Что обязан сделать обработчик, и что произойдёт при следующем запросе с тем же ключом, если Admit вернёт 0?
Задача 4
В уроке 6.1 клиент сохранял операцию до первого запроса, чтобы знать, что отправить при повторе. Теперь сервер берёт на себя ответственность: получив повтор с тем же Idempotency-Key, он возвращает прежний ответ без нового вызова Create. Один ключ охватывает все попытки — это и есть смысл idempotency на стороне сервера.
Реализуйте единственную функцию:
func NewLabelHandler(
store LabelStore,
admit func(context.Context) int,
create func(context.Context, LabelOperation) Response,
) http.Handler
Сигнатура зафиксирована; типы, fake-store и вспомогательные функции предоставлены в файле задачи. Больше ничего менять не нужно.
Контракт запроса
Единственный маршрут: POST /labels. Проверки выполняются строго в этом порядке; первое нарушение прекращает обработку:
- Путь не
/labels→404,Content-Type: application/json, тело{"error":"not_found"} - Метод не
POST→405,Allow: POST,Content-Type: application/json, тело{"error":"method_not_allowed"} Content-Typeнеapplication/json(параметры допустимы) →415, тело{"error":"unsupported_media_type"}- Тело больше 4096 байт →
413, тело{"error":"body_too_large"} - Невалидный JSON, лишнее или отсутствующее поле,
null, нестроковое значение, пустая строка, символ вне[A-Za-z0-9_-], второе JSON-значение →400, тело{"error":"invalid_request"} - Заголовок
Idempotency-Keyотсутствует или не равенrequest_idиз тела →400, тело{"error":"invalid_request"}
Объект содержит ровно два поля: request_id и parcel_id. Повторяющиеся имена полей и варианты с отличием только в регистре исключены из входов и оценивания. Все ответы ошибок имеют Content-Type: application/json и точные байты тела без завершающей новой строки. Ошибки проверки пути, метода и входного запроса не обращаются к store или callbacks.
Fingerprint
После валидации вычислите fingerprint:
sha256([]byte(request_id + "\n" + parcel_id))
→ lowercase hex, 64 символа
Это нормализованное представление намерения: порядок полей и пробелы в JSON не влияют на результат. Fingerprint — средство сравнения, не подпись.
Четыре ветки Claim
Передайте r.Context() во все вызовы store и callbacks — никаких новых контекстов. Вызовите store.Claim(ctx, op.RequestID, fp).
ClaimConflict— fingerprint не совпал с существующей записью:409, тело{"error":"idempotency_conflict"}. Никаких Admit, Create, Complete или Release; запись не меняется.ClaimPending— тот же fingerprint, операция не завершена:409, тело{"error":"request_pending"}. Никаких Admit, Create, Complete или Release; pending сохраняется.ClaimReplay— тот же fingerprint, операция завершена: записать сохранённый статус, точные байты тела и все значения всех предоставленных заголовков. Никаких callbacks и изменений записи. ИспользуйтеHeader.Addв цикле по значениям —Header.Setпотеряет дополнительные значения.ClaimNew— ключ свободен, pending-резервация создана: перейти к Admit.
Ветка ClaimNew: Admit, Create, Complete
Вызовите admit(ctx). Он возвращает только 0, 429 или 503.
При 429 или 503:
- Вызовите
store.Release(ctx, op.RequestID)— освободить pending-резервацию. - Ответить тем же статусом (429 или 503), заголовок
Retry-After: 2, тело{"error":"not_admitted"}. - Не вызывать
CreateиComplete.
После Release запись исчезает. Следующий запрос с тем же ключом получит ClaimNew снова и при успешном Admit сделает единственный Create.
При 0:
- Вызовите
create(ctx, op)— получить готовыйResponseсо статусом 201. - Вызовите
store.Complete(ctx, op.RequestID, resp)— сохранить ответ до записи клиенту. - Записать все заголовки из
resp.HeaderчерезAdd, затемWriteHeader(resp.Status), затемWrite(resp.Body).
Complete вызывается до записи ответа: если соединение оборвётся после WriteHeader, следующий повтор получит ClaimReplay с сохранённым результатом. Обратный порядок оставит окно, в котором повтор попадёт в ClaimPending.
Пример трассы: первый успех и replay
POST /labels
Content-Type: application/json
Idempotency-Key: label-req-7
{"request_id":"label-req-7","parcel_id":"parcel-42"}
→ ClaimNew → Admit=0 → Create label-901 → Complete → 201
Body: {"request_id":"label-req-7","state":"created","label_id":"label-901"}
Location: /labels/label-901
ETag: "label-901-v1"
Повтор того же запроса:
→ ClaimReplay → 201, те же байты, те же заголовки; Create count=1
Повтор с parcel_id=parcel-99, тот же ключ:
→ ClaimConflict → 409 {"error":"idempotency_conflict"}; запись не меняется
Пример трассы: pre-admission отказ
POST /labels Idempotency-Key: label-req-7
→ ClaimNew → Admit=429 → Release → 429
Retry-After: 2
Body: {"error":"not_admitted"}
(записей в store нет)
Повтор того же запроса, Admit=0:
→ ClaimNew → Admit=0 → Create → Complete → 201
(ровно одна запись, ровно один Create)
Проверяемое поведение
Предоставленный store атомарно сравнивает fingerprint до проверки pending/completed и делает глубокие копии bytes и headers при сохранении и replay. Для обработчика проверяется:
- Статус, точные байты тела и все значения каждого заголовка.
- Количество вызовов
CreateиComplete. - Порядок вызовов:
Claim → Admit → Create → Complete → write. - Отсутствие callbacks при replay, conflict и pending.
- Отсутствие записи в store после pre-admission отказа.
- Два разных ключа для одного
parcel_idдают дваCreate. - Эквивалентный JSON с другим порядком полей даёт replay, а не conflict.
- Context из
r.Context()доходит до каждого зависимого вызова.
Ограничения задачи
Реализуйте только NewLabelHandler. Store, типы и вспомогательные функции предоставлены и не меняются. Mutex, SQL и deep copy — ответственность store. GET из предыдущего урока здесь реализовывать не требуется. Retry, backoff и бюджет попыток относятся к следующим урокам. Принятая незавершённая операция не освобождается по таймауту клиента. Произвольный сетевой 503 не означает pre-admission отказ. Один заголовок Idempotency-Key сам по себе не предотвращает дублирование без общего реестра. Отдельные вызовы Create и Complete не гарантируют атомарность при падении между ними.
Оба поля обязательны и являются непустыми строками, целиком соответствующими [A-Za-z0-9_-]+. Проверка размера тела предшествует декодированию JSON. Для 429/503 сначала выполните Release, затем записывайте ответ клиенту; освобождается только резервация, полученная через ClaimNew. Для принятого ответа сохраняются статус 201, исходные bytes и все значения Content-Type, Location, ETag и других предоставленных headers. Date и hop-by-hop headers не входят в гарантируемую сохранённую часть.
Вызовы store не могут завершиться ошибкой; Create гарантированно возвращает готовый корректный Response со статусом 201. Обработка ошибок зависимостей исключена из задачи. В учебной задаче один пользователь и один endpoint; production namespace ключа, общий устойчивый реестр, восстановление зависшего pending и expiry не реализуются. Функция реализует только зафиксированный контракт; отдельные Create и Complete не доказывают exactly-once.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
