Опирается на правила: 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-Key retry запрещён (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.IdempotencyConflictError409 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-контракту по трём причинам:

  1. Без него auth бесполезен для money. Клиент с валидным токеном может retry-нуть ConfirmPayment — без идемпотентности каждый retry → новое списание.
  2. Legitimate retry становится инцидентом. Network timeout → пользователь нажал «оплатить» повторно → два списания вместо одного.
  3. Это контракт, как Authorization. Endpoint без Idempotency-Key на money-команде отклоняется так же, как запрос без токена — 400, не 200 с неопределённым результатом.

Что запрещено

АнтипаттернПравилоЧто взамен
Money-endpoint без RequireIdempotencyKey middlewareAUTH-19r.With(middleware.RequireIdempotencyKey) на каждом money-маршруте
Idempotency-Key необязателен для moneyAUTH-19отсутствие заголовка → 400 Bad Request
Проверка дубля в контроллере, не в HandlerAUTH-19логика проверки — в Handler
idempotency_record без TTLAUTH-19expires_at + индекс + cleanup
Только application-уровень без БД UNIQUEAUTH-19UNIQUE constraint (order_id, idempotency_key) на payment
Тот же ключ для разных команд без command_hashAUTH-19проверка command_hash409 Conflict
Idempotency-Key в теле запросаAUTH-19только заголовок; в context.Context через middleware
Retry write-операции без Idempotency-KeyR-ERR-RETRY-3retry только для идемпотентных операций

Куда дальше

  • Где какая проверка делается — где в слоях ставятся 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.