Опирается на правила:
AUTH-19из контракта auth-patterns → раздел 8. Идемпотентность как часть auth-контракта.
Важно знать
- Money-команды требуют заголовок
Idempotency-Key— обязательный, отсутствие →400 Bad Request.- Повторный вызов с тем же ключом возвращает сохранённый результат, не создаёт дубль.
- В Go идемпотентность — отдельный chi-middleware
RequireIdempotencyKey; проверка дубля — в Handler, не в контроллере.- Ответ на повтор берётся из
idempotency_recordчерез sqlc; повторный dispatch UseCase не вызывается.- Тот же ключ + другое тело →
409 Conflict; защита поcommand_hash.- Money-операции защищаются двойным барьером:
idempotency_record+UNIQUE (order_id, idempotency_key)на уровне БД.Idempotency-Key— заголовок-контракт, не поле тела запроса;RequireIdempotencyKeyкладёт его вcontext.Context.- Retry только для идемпотентных write-операций — без
Idempotency-Keyretry запрещён (R-ERR-RETRY-3).
Money-команды нельзя выполнять дважды. Сетевой timeout не означает, что write не произошёл — он означает неопределённость. Retry без идемпотентности → двойное списание. AUTH-19 формулирует требование как часть auth-контракта: «правильно подписанный запрос обрабатывается ровно один раз».
Какие команды требуют Idempotency-Key
Критерий AUTH-19: команда меняет деньги или резерв.
| Команда | Нужен Idempotency-Key? |
|---|---|
CreateOrder | Да — резервирование |
ConfirmPayment | Да — money |
RefundPayment | Да — money |
ChargeBalance | Да — money |
ReserveInventory | Да — резерв |
CancelOrder | Да — может откатить payment |
UpdateOrderStatus | Нет (если не triggers payment) |
GetOrderByID | Нет (read-only) |
UpdateCustomerProfile | Нет (не money) |
Общее правило: «если retry с тем же payload требует compensation на стороне backend — нужен Idempotency-Key».
Middleware: RequireIdempotencyKey
Middleware ставится только на money-маршруты через r.With(...), не на весь роутер.
// adapters/in/http/middleware/idempotency.go
package middleware
type ctxKey string
const idempotencyKeyCtx ctxKey = "idempotency-key"
func RequireIdempotencyKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
httperr.Write(w, r, &apperr.ValidationError{
Field: "Idempotency-Key",
Message: "header required",
})
return
}
ctx := context.WithValue(r.Context(), idempotencyKeyCtx, key)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func IdempotencyKeyFrom(ctx context.Context) string {
v, _ := ctx.Value(idempotencyKeyCtx).(string)
return v
}
// adapters/in/http/router.go
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer"))
r.With(middleware.RequireIdempotencyKey).
Post("/orders", h.CreateOrder)
r.With(middleware.RequireIdempotencyKey).
Post("/orders/{id}/payment/confirm", h.ConfirmPayment)
r.With(middleware.RequireIdempotencyKey).
Post("/orders/{id}/refund", h.RefundPayment)
r.Get("/orders/{id}", h.GetOrder)
})
RequireIdempotencyKey не устанавливается на группу — только явно через r.With(...) на каждый money-маршрут. Так читатель видит контракт прямо в роутере.
Схема idempotency_record
-- db/migrations/V004__idempotency_record.sql
CREATE TABLE idempotency_record (
idempotency_key text NOT NULL,
command_hash text NOT NULL,
http_status int NOT NULL,
response_body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (idempotency_key)
);
CREATE INDEX ON idempotency_record (expires_at);
TTL — 24–72 часа в зависимости от домена: CreateOrder — 72 ч, ConfirmPayment / RefundPayment — 24 ч. Запись с истёкшим expires_at обрабатывается как новый вызов.
Для payment — дополнительный барьер:
-- db/migrations/V005__payment_idempotency_unique.sql
ALTER TABLE payment
ADD COLUMN idempotency_key text NOT NULL,
ADD CONSTRAINT uq_payment_idempotency UNIQUE (order_id, idempotency_key);
sqlc-запросы
-- db/query/idempotency.sql
-- name: FindIdempotencyRecord :one
SELECT idempotency_key, command_hash, http_status, response_body
FROM idempotency_record
WHERE idempotency_key = $1
AND expires_at > now();
-- name: InsertIdempotencyRecord :exec
INSERT INTO idempotency_record
(idempotency_key, command_hash, http_status, response_body, expires_at)
VALUES ($1, $2, $3, $4, now() + $5::interval);
Port и adapter
// core/idempotency/port.go
package idempotency
type Record struct {
CommandHash string
HTTPStatus int
ResponseBody json.RawMessage
}
type Repository interface {
Find(ctx context.Context, key string) (*Record, error)
Save(ctx context.Context, key string, r Record, ttl time.Duration) error
}
// adapters/out/postgres/idempotency_repo.go
package postgres
type IdempotencyRepo struct {
q *db.Queries
}
func (r *IdempotencyRepo) Find(ctx context.Context, key string) (*idempotency.Record, error) {
row, err := r.q.FindIdempotencyRecord(ctx, key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("idempotency find %s: %w", key, err)
}
return &idempotency.Record{
CommandHash: row.CommandHash,
HTTPStatus: int(row.HttpStatus),
ResponseBody: row.ResponseBody,
}, nil
}
func (r *IdempotencyRepo) Save(
ctx context.Context,
key string,
rec idempotency.Record,
ttl time.Duration,
) error {
err := r.q.InsertIdempotencyRecord(ctx, db.InsertIdempotencyRecordParams{
IdempotencyKey: key,
CommandHash: rec.CommandHash,
HttpStatus: int32(rec.HTTPStatus),
ResponseBody: rec.ResponseBody,
Column5: ttl.String(),
})
if err != nil {
return fmt.Errorf("idempotency save %s: %w", key, err)
}
return nil
}
Handler: проверка дубля
Логика проверки и сохранения — в Handler, не в контроллере.
// core/order/handler/create_order.go
package handler
type CreateOrderHandler struct {
orders order.Repository
idempotent idempotency.Repository
policy *order.OrderAccessPolicy
}
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (order.Order, error) {
key := middleware.IdempotencyKeyFrom(ctx)
hash := sha256hex(cmd)
existing, err := h.idempotent.Find(ctx, key)
if err != nil {
return order.Order{}, fmt.Errorf("idempotency check: %w", err)
}
if existing != nil {
if existing.CommandHash != hash {
return order.Order{}, &apperr.IdempotencyConflictError{Key: key}
}
var o order.Order
if err := json.Unmarshal(existing.ResponseBody, &o); err != nil {
return order.Order{}, fmt.Errorf("idempotency unmarshal: %w", err)
}
return o, nil
}
o := order.New(cmd.CustomerID, cmd.Items)
if err := h.orders.Save(ctx, &o); err != nil {
return order.Order{}, fmt.Errorf("save order: %w", err)
}
body, _ := json.Marshal(o)
if err := h.idempotent.Save(ctx, key, idempotency.Record{
CommandHash: hash,
HTTPStatus: http.StatusCreated,
ResponseBody: body,
}, 72*time.Hour); err != nil {
slog.ErrorContext(ctx, "idempotency save failed", "key", key, "error", err)
}
return o, nil
}
func sha256hex(v any) string {
b, _ := json.Marshal(v)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
Ошибка сохранения idempotency_record логируется, но не роллирует успешную операцию — write уже произошёл, UNIQUE constraint на payment поймает дубль если придёт повтор.
Контроллер
Контроллер не знает об идемпотентности — он читает ответ из Handler и отправляет клиенту.
// adapters/in/http/order_controller.go
func (c *OrderController) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, &apperr.ValidationError{Field: "body", Message: "invalid json"})
return
}
principal := security.PrincipalFrom(r.Context())
cmd := handler.CreateOrderCommand{
CustomerID: principal.Sub,
Items: req.Items,
}
o, err := c.createOrder.Handle(r.Context(), cmd)
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(toResponse(o))
}
httperr.Write маппирует apperr.IdempotencyConflictError → 409 Conflict.
Ошибка типа и маппинг
// core/apperr/idempotency.go
type IdempotencyConflictError struct {
Key string
}
func (e *IdempotencyConflictError) Error() string {
return fmt.Sprintf("idempotency conflict: key=%s", e.Key)
}
func (e *IdempotencyConflictError) Kind() Kind { return Conflict }
// adapters/in/http/httperr/render.go
func KindToStatus(k apperr.Kind) int {
switch k {
case apperr.Conflict:
return http.StatusConflict
case apperr.Unauthenticated:
return http.StatusUnauthorized
case apperr.Forbidden:
return http.StatusForbidden
case apperr.Validation:
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
Почему это часть auth-контракта
Idempotency-Key — не оптимизация, а часть того, что значит «правильно подписанный запрос обрабатывается». UCP относит его к auth-контракту по трём причинам:
- Без него auth бесполезен для money. Клиент с валидным токеном может retry-нуть
ConfirmPayment— без идемпотентности каждый retry → новое списание. - Legitimate retry становится инцидентом. Network timeout → пользователь нажал «оплатить» повторно → два списания вместо одного.
- Это контракт, как
Authorization. Endpoint безIdempotency-Keyна money-команде отклоняется так же, как запрос без токена —400, не200с неопределённым результатом.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Money-endpoint без RequireIdempotencyKey middleware | AUTH-19 | r.With(middleware.RequireIdempotencyKey) на каждом money-маршруте |
Idempotency-Key необязателен для money | AUTH-19 | отсутствие заголовка → 400 Bad Request |
| Проверка дубля в контроллере, не в Handler | AUTH-19 | логика проверки — в Handler |
idempotency_record без TTL | AUTH-19 | expires_at + индекс + cleanup |
| Только application-уровень без БД UNIQUE | AUTH-19 | UNIQUE constraint (order_id, idempotency_key) на payment |
Тот же ключ для разных команд без command_hash | AUTH-19 | проверка command_hash → 409 Conflict |
Idempotency-Key в теле запроса | AUTH-19 | только заголовок; в context.Context через middleware |
Retry write-операции без Idempotency-Key | R-ERR-RETRY-3 | retry только для идемпотентных операций |
Куда дальше
- Где какая проверка делается — где в слоях ставятся auth-барьеры.
- Авторизация по ресурсу (ABAC) — владение агрегатом +
AccessPolicy. - Роли и маппинг (RBAC) —
RequireRolesиextractRolesиз JWT. - Service-to-service — mTLS и Client Credentials для outbound-клиентов.
- Аудит admin-команд —
audit.Logger+ sqlc для state-changing команд. - JWT validation —
JWTValidator,keyfunc/v3, JWKS-кеш. - PII и секреты — что не попадает в логи и
error.Error(). - Хранение токенов (BFF) — HttpOnly cookie и RT rotation.