Опирается на правила: AUTH-19 из Auth Patterns Style Guide → раздел 8. Идемпотентность как часть auth-контракта.

Важно знать

  • Money-команды требуют Idempotency-Key header — обязательный, без него 400 Bad Request.
  • Повторный вызов с тем же ключом возвращает прежний результат, не создаёт дубль.
  • Защита от retry клиента, retry мобильного приложения, retry в S2S, network timeout.
  • Без Idempotency-Key два списания одного клиента — money-инцидент.
  • Идемпотентность — это auth-контракт: «правильно подписанный запрос обрабатывается ровно один раз».
  • Тот же ключ + другой payload → 409 Conflict — клиент пытается переиспользовать ключ.
  • В FastAPI ключ читается через Header(...) — Depends-зависимость проверяет наличие.

Money-команды не должны выполняться дважды от одного клиента. Кажется очевидным, но сетевой timeout не означает, что write не произошёл — он означает, что мы не знаем, произошёл или нет. Retry без идемпотентности → двойное списание. UCP формулирует требование как часть auth-контракта, не optional optimization.

Какие команды требуют Idempotency-Key

AUTH-19: критерий — money или резерв.

КомандаНужен Idempotency-Key?
CreateOrderДа — резервирование / payment
ConfirmPaymentДа — money
RefundPaymentДа — money
ChargeBalanceДа — money
CreditBalanceДа — money
ReserveInventoryДа — резерв
CancelOrderДа — может откатить payment
UpdateOrderStatusНет (если не triggers payment)
GetOrderByIdНет (read-only)
UpdateCustomerProfileНет (не money)

Общее правило: «если retry с тем же payload может потребовать компенсации на стороне backend — нужен Idempotency-Key».

Контракт

Клиент:

POST /orders
Authorization: Bearer ...
Idempotency-Key: 0193a8f3-7c21-7e3f-9b4a-...
Content-Type: application/json

{ "customer_id": 42, "items": [...] }

Backend:

  • Первый вызов: обрабатывает, сохраняет (idempotency_key, command_hash, response) в idempotency_record, возвращает 201 Created + body.
  • Повтор с тем же ключом + тем же payload: возвращает сохранённый response. Не создаёт дубль.
  • Тот же ключ + другой payload: 409 Conflict — клиент пытается переиспользовать ключ.
  • Без header: 400 Bad RequestIdempotency-Key required.

Зависимость: проверка ключа на входе

FastAPI читает Idempotency-Key через Header(...). Отсутствие заголовка FastAPI преобразует в 422, но по контракту AUTH-19 нужен 400 — поэтому используется Depends c явной проверкой:

# adapters/in/http/security.py
from fastapi import Header, HTTPException

async def require_idempotency_key(
    idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> str:
    if not idempotency_key:
        raise HTTPException(status_code=400, detail="Idempotency-Key required")
    return idempotency_key

Роутер: CreateOrder

# adapters/in/http/order_router.py
from fastapi import APIRouter, Depends, Response
from app.security import principal, require_roles, require_idempotency_key
from app.idempotency import IdempotencyStore
from core.use_cases.create_order import CreateOrderCommand
from core.ports.use_case_dispatcher import UseCaseDispatcher

router = APIRouter(prefix="/orders")


@router.post("", status_code=201)
async def create_order(
    body: CreateOrderRequest,
    idem_key: str = Depends(require_idempotency_key),
    p: Principal = Depends(require_roles("customer")),
    idem_store: IdempotencyStore = Depends(),
    dispatcher: UseCaseDispatcher = Depends(),
) -> OrderResponse:
    command_hash = _hash(body)

    existing = await idem_store.find(idem_key)
    if existing:
        if existing.command_hash != command_hash:
            raise HTTPException(status_code=409, detail="idempotency key reused with different payload")
        return Response(
            content=existing.response_body,
            status_code=existing.http_status,
            media_type="application/json",
        )

    command = CreateOrderCommand(
        idempotency_key=idem_key,
        customer_id=p.sub,
        items=body.items,
    )
    order = await dispatcher.dispatch(command)
    response = OrderResponse.from_domain(order)

    await idem_store.save(
        key=idem_key,
        command_hash=command_hash,
        response_body=response.model_dump_json(),
        http_status=201,
    )
    return response

require_roles("customer") (AUTH-9) и require_idempotency_key (AUTH-19) подключены параллельно как независимые Depends — FastAPI выполнит оба до вызова handler'а.

IdempotencyStore

Хранилище — тонкая обёртка над БД, без бизнес-логики:

# adapters/out/db/idempotency_store.py
from dataclasses import dataclass
from datetime import datetime, timedelta, UTC
import hashlib, json
from sqlalchemy.ext.asyncio import AsyncSession

TTL = timedelta(hours=48)


@dataclass
class IdempotencyRecord:
    idempotency_key: str
    command_hash: str
    response_body: str
    http_status: int
    created_at: datetime


class IdempotencyStore:
    def __init__(self, session: AsyncSession):
        self._session = session

    async def find(self, key: str) -> IdempotencyRecord | None:
        row = await self._session.execute(
            "SELECT * FROM idempotency_record WHERE idempotency_key = :key AND created_at > :cutoff",
            {"key": key, "cutoff": datetime.now(UTC) - TTL},
        )
        return IdempotencyRecord(**row.mappings().one()) if row.rowcount else None

    async def save(
        self, key: str, command_hash: str, response_body: str, http_status: int
    ) -> None:
        await self._session.execute(
            """
            INSERT INTO idempotency_record (idempotency_key, command_hash, response_body, http_status, created_at)
            VALUES (:key, :hash, :body, :status, now())
            ON CONFLICT (idempotency_key) DO NOTHING
            """,
            {"key": key, "hash": command_hash, "body": response_body, "status": http_status},
        )


def _hash(body: object) -> str:
    return hashlib.sha256(json.dumps(body, sort_keys=True, default=str).encode()).hexdigest()

ON CONFLICT DO NOTHING — защита от race condition: если два параллельных запроса с одним ключом прошли проверку find() одновременно, первый победит на INSERT, второй получит пустой результат и перечитает запись на следующем find().

Почему «часть auth-контракта»

Idempotency-Key — не feature. UCP формулирует его в auth-контексте, потому что:

  1. Без него auth бесполезен. Атакующий с украденным Bearer может retry money-команду — без идемпотентности списание дублируется.
  2. Без него legitimate retry становится атакой на пользователя — клиент думает «не отправилось», повторяет, два списания.
  3. Это контракт между client и server, как Authorization header — без него запрос отклоняется 400.

Money — двойная защита

Дополнительно к application-уровню idempotency_record, money-операции защищаются БД unique-constraint:

CREATE TABLE payment (
    id               bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id         bigint       NOT NULL,
    idempotency_key  text         NOT NULL,
    amount           numeric(19,4) NOT NULL,
    status           text         NOT NULL,
    created_at       timestamptz  NOT NULL DEFAULT now(),
    UNIQUE (order_id, idempotency_key)
);

CREATE TABLE idempotency_record (
    idempotency_key  text        PRIMARY KEY,
    command_hash     text        NOT NULL,
    response_body    text        NOT NULL,
    http_status      smallint    NOT NULL,
    created_at       timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON idempotency_record (created_at);

Если idempotency_record пропустил retry (разные клиенты использовали разные ключи), UNIQUE (order_id, idempotency_key) ловит дубль на уровне БД.

ConfirmPayment и Refund

Те же принципы, отдельные роутеры:

# adapters/in/http/payment_router.py
@router.post("/{order_id}/payment/confirm", status_code=200)
async def confirm_payment(
    order_id: int,
    body: ConfirmPaymentRequest,
    idem_key: str = Depends(require_idempotency_key),
    p: Principal = Depends(require_roles("customer")),
    idem_store: IdempotencyStore = Depends(),
    dispatcher: UseCaseDispatcher = Depends(),
) -> PaymentResponse:
    ...


@router.post("/{order_id}/payment/refund", status_code=200)
async def refund_payment(
    order_id: int,
    body: RefundRequest,
    idem_key: str = Depends(require_idempotency_key),
    p: Principal = Depends(require_roles("customer", "admin")),
    idem_store: IdempotencyStore = Depends(),
    dispatcher: UseCaseDispatcher = Depends(),
) -> RefundResponse:
    ...

Структура handler'а одна для всех money-команд: find → conflict-check → dispatch → save → return.

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

АнтипаттернПравилоЧто взамен
Money-endpoint без Idempotency-Key обязательногоAUTH-19Depends(require_idempotency_key), иначе 400
Idempotency-Key как Optional для moneyAUTH-19обязателен через Depends
Retry с новым Idempotency-KeyAUTH-19один ключ на бизнес-операцию; клиент генерирует UUID заранее
idempotency_record без TTLAUTH-19created_at > cutoff, TTL = 48h
Идемпотентность только на application, без БД UNIQUEAUTH-19двойная защита для money
Одинаковый ключ для разных командAUTH-19проверка command_hash409
Idempotency-Key в query string, не в headerAUTH-19header (query string — для resource id)
Проверка ключа внутри UseCase/HandlerAUTH-19только в роутере; UseCase не знает о transport-деталях

Куда дальше

  • Где какая проверка — Gateway, BFF и Domain Service — слои auth-проверок и их ответственность.
  • JWT validation — PyJWT, PyJWKClient и JWKS-кеш — AUTH-4..6 в FastAPI.
  • RBAC — маппинг ролей и require_roles — Depends(require_roles(...)) на каждом endpoint.
  • ABAC — владение ресурсом в Handler — order.customer_id == principal.sub, 403.
  • Audit admin-команд — декоратор и *_audit_log — AUTH-15, каждое admin-действие.
  • PII и секреты — что нельзя в логи и detail — AUTH-16..18 в FastAPI.
  • Service-to-service — mTLS и Client Credentials — AUTH-13..14, анонимный трафик запрещён.
  • Хранение токенов — HttpOnly cookie и refresh rotation — AUTH-20..21, localStorage запрещён.