Стратегії повторів та ідемпотентність
1) Навіщо це потрібно
У мережах збої - норма: таймаути, transient-помилки, мережеві флаппінги, перевантаження. Ретраї підвищують надійність тільки якщо:1. повтор безпечний (ідемпотентний),
2. дотримуються витримки між повторами,
3. поважаються ліміти/квоти і «здоров'я» залежностей.
Мета - effectively-once поведінка на рівні бізнес-операцій без помилкових дублів і гонок.
2) Таксономія семантики доставки
At-most-once: без повторів, ризик втрати (логування, fire-and-forget).
At-least-once: можливі дублікати → потрібна ідемпотентність споживача (більшість черг, вебхуки).
Effectively-once: дублікати можливі, але коректно дедуплікуються (ключі, транзакції, outbox).
3) Коли ретраїти, а коли ні
Ретраїти має сенс: '408','429'( дотримуючись'Retry-After'),'425'( Too Early),'499'( client closed на периметрі),'5xx','504', мережеві таймаути/розриви,'502'у шлюзу, «connection reset».
Не ретраїмо без зміни запиту: `400/401/403/404/422`.
Спірні кейси: '409 Conflict'( зазвичай не ретраїмо; спочатку читаємо статус операції/перепідтверджуємо намір).
4) Таймаути, backoff і джиттер
4. 1 Правила
Спочатку таймаут, потім ретраії: у кожного запиту повинен бути «deadline».
Exponential backoff: 'delay _ n = base 2 ^ n', обмежуємо'max _ delay'.
Jitter обов'язковий: додавайте випадковість для розв'язки «тупих синхронних хвиль».
4. 2 Шаблони джиттера
Full jitter: 'sleep = rand (0, base2 ^ n)'- найкращий загальний вибір.
Decorrelated jitter: 'sleep = min (max_delay, rand (base, sleep_prev3))'- для довгих діалогів.
Equal jitter: 'sleep = base2 ^ n/2 + rand (0, base2 ^ n/2)'- м'яка варіація.
4. 3 Retry-budget
Обмежте частку ретраїв:- `retry_budget_per_min = max(α success_rps, floor β)`; зазвичай'α = 0. 1–0. 2`.
- При вичерпанні бюджету - перемикаємося на fail-fast/circuit breaker «open».
5) Взаємодія з rate limiting і Circuit Breaker
Поважайте'Retry-After','RateLimit-Reset'і вважайте це в бек-офф.
При високих «5хх »/таймаутах - знижуйте частоту ретраїв і загальний паралелізм.
- Half-open: допускає обмежену пробу.
- Open: миттєво відхиляє (економить ресурс).
- Closed: Звичайна робота.
- На write-операціях краще повернути 409/503 з чіткою підказкою, ніж крутити агресивні ретраї.
6) Ідемпотентність write-операцій
6. 1 Загальна ідея
Однакові наміри → один результат. Основа - ключ ідемпотентності та сховище записів виконання.
6. 2 HTTP-контракт
Клієнт посилає заголовок:
Idempotency-Key: 7a6b7f9e-2a46-4d0b-9c3a-2b30e1c3c9e3
Idempotency-Key-Expiry: 24h # optional
Сервер:
- при першому успішному виконанні зберігає (ключ → результат, статус, хеш тіла);
- при повторі повертає колишню відповідь і заголовок'Idempotency-Replay: true`;
- при конфлікті тіла (той же ключ, але інший payload) -'409 Conflict'.
6. 3 Сховище і TTL
Таблиця/ключ-значення: `idempotency_key`, `request_hash`, `result`, `status`, `expiry_at`.
TTL = вікно можливих повторів і пізніх доставок (зазвичай 24-72 год для платежів).
Індекси по'idempotency _ key'; для високого навантаження - шардування по хешу.
6. 4 Приклад схеми (SQL)
sql
CREATE TABLE idempo_store (
key UUID PRIMARY KEY,
req_hash BYTEA NOT NULL,
status INT NOT NULL,
response JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expiry_at TIMESTAMPTZ NOT NULL
);
6. 5 Псевдокод обробника
pseudo handle_write(req):
k = req. headers["Idempotency-Key"]
h = hash(req. body)
rec = idempo_store. get(k)
if rec and rec. req_hash == h:
return rec. status, rec. response, {"Idempotency-Replay": "true"}
if rec and rec. req_hash!= h:
return 409, problem("IDEMPOTENT_CONFLICT")
begin tx result = apply_business_mutation (req) # change status upsert once (idempo_store, key = k, req_hash=h, status = 201, response = result, expiry = now () + 2d)
commit
return 201, result
7) Патерни «effectively-once»
Transactional Outbox: запис бізнес-події і відправка повідомлення з тієї ж БД-транзакції через фонового релейера; споживач ідемпотентний.
Inbox/Processed-table у споживача: зберігаємо'event _ id', щоб ігнорувати дублі.
Exactly-once на Kafka ≠ exactly-once в бізнесі: навіть при EOS продюсера/консьюмера прикладна логіка все одно повинна бути ідемпотентною.
Компенсуючі транзакції (Saga): якщо кроки ретраяться і викликають побічні ефекти, повертаємо систему до інваріанту.
8) Приватні випадки: платежі та фінансові операції
Strong idempotency: ключ прив'язаний до логіки операції (наприклад,'external _ payment _ id').
Дедуплікація на PSP: зберігайте'merchant _ reference'→ при повторі PSP поверне колишній результат.
Ретраї «від клієнта»: дозволяти тільки при'Idempotency-Key', інакше ризик подвійного списання.
Конкурентність: блокування «на аккаунт/інструмент/договір» на час виконання; при повторі повертайте 409/423.
Спостережуваність: метрики `idempo_replay_total`, `idempo_conflict_total`.
9) Вебхуки і зовнішні виклики
Підписи HMAC і вікно часу; спочатку перевірка, потім обробка.
Ретраї відправника: експоненціальний backoff + джиттер,'max _ attempts'і DLQ.
Споживач - ідемпотентний: 'event _ id'→ таблиця/in-memory cache; «акуратний» порядок не гарантований.
Коди: 2xx = успішно, 4xx = не повторювати, 5хх/таймаут = повторити.
10) Черги та фонові завдання
At-least-once за замовчуванням → дублікати неминучі.
Зберігайте'task _ id '/' event _ id'і статус виконання; при дублі - короткий шлях «replay».
DLQ и poison-messages: лічильник спроб, карантин, ручний розбір.
Конкурентні ліміти (семафори) та ідемпотентні воркери.
11) Версіонування та «натуральні» ключі
Натуральні ключі (номер рахунку + дата + номер документа) підвищують стійкість до повторів.
При зміні схеми/версії включайте ключ версії в'Idempotency-Key'або в хеш запиту.
12) HTTP-заголовки та підказки клієнту
`Idempotency-Key`, `Idempotency-Replay`, `Retry-After`, `Prefer: wait = <sec>'( на довгих операціях),'If-Match '/' ETag'( оптимістичні блокування).
409 при конфлікті ключа, 425/429/503 з валідним'Retry-After'.
Для «довгих» операцій - прийом асинхронного статусу ('202 Accepted'+'Location'на ресурс статусу).
13) Тестування та хаос-сценарії
Negative-тести: подвійна відправка, повтор з іншим тілом, розсинхрон годинника.
Порушення порядку: 't2'приходить раніше't1'.
Ін'єкція таймаутів/' RST '/' EOF', половинчасті запити (slow-POST).
Впало сховище idempotency → поведінка fail-closed (краще відмова, ніж подвійне списання).
14) Метрики та алерти
`retries_total{reason}`, `retry_budget_used{route}`, `backoff_seconds_bucket`.
`idempo_replay_total`, `idempo_conflict_total`, `duplicate_detected_total`.
Частка 409/425/429/5xx за маршрутами; p95/p99 «часу до успіху» з ретраями.
Алерти: burn-rate бюджету ретраїв, сплеск конфліктів ідемпотентності, зростання DLQ.
15) Антипатерни
Ретраїти всі помилки підряд.
Відсутність джиттера → синхронні хвилі ретраїв.
Довгоживучі ключі без TTL і очищення.
Збереження результату після коміту побічного ефекту (порушення outbox).
Логи без'trace _ id '/' idempotency _ key'→ неможлива форензика.
Агресивні паралельні ретраї на write-операціях.
16) Чек-лист prod-готовності
- Єдина політика: що ретраїмо, що ні; коди та підказки клієнту.
- Експоненціальний backoff + full jitter; заданий'retry _ budget'.
- Контракт'Idempotency-Key'+ зберігання результатів з TTL.
- Outbox/Inbox для подій; DLQ; ліміти конкурентності.
- Інтеграція з circuit breaker, respect'Retry-After'.
- Метрики/алерти по ретраях/дублікатах/конфліктах.
- Набір хаос-тестів і емуляція мережевих збоїв.
- Документація для клієнтів: приклади бек-оффів і статуси.
17) TL; DR
Ретраї корисні тільки разом з ідемпотентністю. Вводьте'Idempotency-Key'і сховище результатів, застосовуйте експоненціальний backoff з джиттером і retry-budget, поважайте'Retry-After', інтегруйтеся з circuit breaker. Для подій - outbox/inbox; для платежів - сувора дедуплікація і блокування. Міряйте ретраї і конфлікти, тестуйте дублікати і таймаути.