Идемпотентность: ключи, 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— он уже идемпотентен по HTTPPUTс 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)
Что такое API простыми словами + примеры
Все говорят «вызови API», «у этого сервиса есть API» — а что это? Объясним через ресторан и официанта. Плюс реальный запрос.
Интеграция стороннего сервиса
План подключения сервиса (Stripe, Supabase, etc.) с учётом ошибок, секретов и тестового режима.
Проектирование REST/RPC API
Ресурсы, эндпойнты, контракты, версионирование, ошибки, идемпотентность, rate limits.