Skip to content
PПромтбук
RUEN
04Архитектура

Идемпотентность: ключи, storage, retry

Idempotency keys (UUID), Redis storage с TTL, retry strategy, edge-cases с concurrent same-key и retry после success.

Спроектируй идемпотентность для {{operations}}. Storage: {{storage}}.

Базовая аксиома: сеть ненадёжна. Клиент послал POST /payments, ответ потерялся, клиент ретраит — без идемпотентности списали дважды. GET идемпотентен по природе HTTP. POST/PATCH/DELETE — нет, и это твоя проблема, не клиента.

1. Что значит «идемпотентный»

Математически: f(f(x)) = f(x). В API: «вызов 10 раз даёт тот же результат, что и 1 раз» — без побочных эффектов сверху первого.

Что это не значит:

  • Не «безопасно для конкаренси» — это про дубли retry, не про race conditions
  • Не «возвращает кэш» — каждый вызов проходит логику, но эффект не дублируется
  • Не «всегда успех» — повторный вызов может вернуть тот же error что первый

2. Кто генерирует ключ

Только клиент. Если ключ генерит сервер — он не знает что это retry.

POST /payments
Idempotency-Key: 5d2c3a8e-1f0b-4f9c-9e1a-c5b8d7e2f1a3
Content-Type: application/json

{ "amount": 100, "currency": "USD", "to": "acc_123" }

Требования к ключу:

  • UUID v4 / ULID / nanoid — крипто-случайный
  • Длина: 16-256 символов (фильтруй на сервере)
  • Один ключ — одна логическая операция. Не переиспользуй между запросами

Клиент должен генерить новый ключ для нового logical operation. SDK обычно делает это автоматически.

3. Жизненный цикл запроса

1. Запрос приходит с Idempotency-Key=K
2. Сервер пытается lock(K) — атомарно claim'ит ключ
   ├─ Lock получен (первый запрос):
   │  ├─ Сохраняет request fingerprint (hash of method + path + body)
   │  ├─ Выполняет операцию
   │  ├─ Сохраняет (status_code, response_body)
   │  └─ Возвращает response клиенту
   │
   ├─ Lock НЕ получен, есть completed response:
   │  ├─ Проверяет fingerprint matches → возвращает saved response
   │  └─ Fingerprint mismatch → 422 «key reuse with different payload»
   │
   └─ Lock НЕ получен, операция in-flight (другой retry прямо сейчас):
      └─ 409 Conflict «request in progress, retry later»

4. Storage схема

Redis:

KEY: idempotency:{tenant_id}:{key}
VALUE (JSON): {
  "status": "in_progress" | "completed",
  "request_hash": "sha256(method+path+canonical_body)",
  "response_status": 200,
  "response_body": "{...}",
  "response_headers": {...},
  "created_at": "2025-05-17T12:00:00Z"
}
TTL: 24 hours

Atomic claim (Lua или SET NX):

SET idempotency:T:K '{"status":"in_progress",...}' NX EX 60
  • NX — только если ключ ещё не существует
  • EX 60 — initial short TTL (60s); потом продлится до 24h когда completed

Если SET NX вернул nil → запись уже есть, читай её.

Postgres вариант:

CREATE TABLE idempotency_keys (
  tenant_id    TEXT NOT NULL,
  key          TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  status       TEXT NOT NULL CHECK (status IN ('in_progress', 'completed')),
  response_status   INTEGER,
  response_body     JSONB,
  response_headers  JSONB,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ,
  PRIMARY KEY (tenant_id, key)
);
CREATE INDEX ON idempotency_keys (created_at) WHERE status = 'completed';

Atomic claim: INSERT ... ON CONFLICT (tenant_id, key) DO NOTHING. Если affected rows = 0 → читай.

5. Retention — сколько хранить

24 часа — индустриальный стандарт (Stripe, PayPal). Достаточно для retry-loop'ов мобильных клиентов с offline; не слишком много данных в БД.

Не делай:

  • Меньше 24h — мобильный клиент в роуминге может приехать с retry через 12 часов
  • Больше 7 дней — пухнет storage, шанс случайно реюзнуть ключ из старого запроса
  • Бесконечно — Redis OOM, Postgres bloat

Cleanup:

  • Redis TTL делает сам
  • Postgres: cron DELETE FROM idempotency_keys WHERE created_at < now() - interval '7 days'

6. Request fingerprint — зачем

Клиент может ошибочно реюзнуть ключ с другим body. Без fingerprint вернёшь старый response, который не соответствует новому запросу.

request_hash = sha256(
  method + "\n" +
  path + "\n" +
  canonical_json(body)   // отсортированные ключи!
)

Сравнение на повторном запросе:

  • Совпадает → safe replay, возвращаем saved response
  • Не совпадает → 422 Unprocessable Entity с X-Idempotency-Conflict: true

Не включай: timestamp, request_id, trace_id — клиент их меняет на retry.

7. Edge cases

Concurrent same-key (два retry одновременно)

Клиент послал, таймаут на стороне клиента, retry — пока первый ещё processing. SET NX не даст второму захватить. Что вернуть?

  • 409 Conflict + Retry-After: 1 — клиент попробует через секунду. Чище всего.
  • Не возвращай 200 с пустым body — клиент подумает что успех
  • Не блокируй (long polling) — занимаешь сервер

Retry приходит после success ответа

Первый запрос вернул 200, клиент не получил (потерялся), retry. Lock уже completed.

  • Проверь fingerprint → возвращаем saved response 200
  • Клиент получает тот же response что должен был

Retry после server-side error

Первый вернул 500. Клиент retry с тем же ключом.

  • Вариант A: возвращаем тот же 500 (idempotent на error)
  • Вариант B: разрешаем повторное выполнение (только если статус 5xx)

Рекомендация: 4xx errors сохраняй (replay безопасен), 5xx — пометь как status: 'failed_retryable', на retry выполни заново.

Что если операция частично выполнилась

Списали деньги, не успели записать ответ, упали. На retry — Lock есть, но completed: false. Опасно — можно списать дважды.

Решение: операция должна быть транзакционной с записью completed в той же транзакции. Если БД — одна, оборачивай в BEGIN ... COMMIT. Если разные системы — saga/outbox (отдельный промт).

Несколько replicas сервера

Все обращаются к одному Redis/Postgres — SET NX / INSERT ON CONFLICT обеспечивают глобальный атомарный claim. Не используй local in-memory cache — каждая реплика своё кеширует, идемпотентность сломана.

8. Retry strategy на стороне клиента

Сервер должен рекомендовать retry стратегию клиенту:

HTTP/1.1 503 Service Unavailable
Retry-After: 2

Клиентский SDK:

attempt = 0
while attempt < MAX_ATTEMPTS:
  response = http.post(url, body, headers={"Idempotency-Key": key})
  if response.status in (200, 201, 4xx_non_retryable):
    return response
  if response.status in (408, 429, 500, 502, 503, 504):
    wait = min(2 ** attempt + random_jitter(), 30)
    sleep(wait)
    attempt += 1
    # KEY ОСТАЁТСЯ ТЕМ ЖЕ — это retry той же операции
  else:
    raise

Exponential backoff + jitter обязателен — иначе после deploy у тебя retry storm.

9. Что НЕ делать идемпотентным

  • GET — он уже идемпотентен по HTTP
  • PUT с whole-resource semantics — тоже idempotent by design
  • Операции с side effects вне твоей системы (отправка email, SMS) — там нужна другая стратегия (job queue с dedupe на consumer side)

Anti-patterns

  • ❌ Сервер генерит idempotency key — он не знает что это retry, бесполезно
  • ❌ Idempotency key = request body hash — клиент случайно изменил пробел → новый ключ → дубль
  • ❌ TTL = бесконечный или 30 дней — storage пухнет, шанс коллизий растёт
  • ❌ Нет request fingerprint — клиент с багом реюзнул ключ → возвращаешь чужой response
  • ❌ In-memory cache в каждой replica вместо общего storage — каждая реплика своё знает
  • ❌ Lock без TTL — упавший воркер навсегда заблокировал ключ
  • ❌ Запись completed в отдельной транзакции от бизнес-операции — частичное состояние возможно
  • ❌ Возвращаем 200 на in-progress — клиент думает успех, на retry дубль
  • ❌ Документация POST /payments idempotent без объяснения как — клиент думает что само работает

В конце

  • Header name (Idempotency-Key стандарт)
  • Storage schema (Redis с TTL 24h ИЛИ Postgres с cleanup)
  • Atomic claim mechanism (SET NX / INSERT ON CONFLICT)
  • Request fingerprint (что включаем в hash)
  • Edge case responses (409 для concurrent, 422 для fingerprint mismatch)
  • Retry guidance для клиентов (status codes, backoff)
  • Список операций которые идемпотентны (whitelisted)
К подразделу «Архитектура»
Похожие промты