Опирается на правила:
AUTH-19из Auth Patterns Style Guide → раздел 8. Идемпотентность как часть auth-контракта.
Важно знать
- Money-команды требуют
Idempotency-Keyheader — обязательный, без него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 Request—Idempotency-Keyrequired.
Зависимость: проверка ключа на входе
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-контексте, потому что:
- Без него auth бесполезен. Атакующий с украденным
Bearerможет retry money-команду — без идемпотентности списание дублируется. - Без него legitimate retry становится атакой на пользователя — клиент думает «не отправилось», повторяет, два списания.
- Это контракт между client и server, как
Authorizationheader — без него запрос отклоняется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-19 | Depends(require_idempotency_key), иначе 400 |
Idempotency-Key как Optional для money | AUTH-19 | обязателен через Depends |
Retry с новым Idempotency-Key | AUTH-19 | один ключ на бизнес-операцию; клиент генерирует UUID заранее |
idempotency_record без TTL | AUTH-19 | created_at > cutoff, TTL = 48h |
| Идемпотентность только на application, без БД UNIQUE | AUTH-19 | двойная защита для money |
| Одинаковый ключ для разных команд | AUTH-19 | проверка command_hash → 409 |
Idempotency-Key в query string, не в header | AUTH-19 | header (query string — для resource id) |
| Проверка ключа внутри UseCase/Handler | AUTH-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запрещён.