Стратегии повторов и идемпотентность
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` и считайте это в бэк-офф.
При высоких `5xx`/таймаутах — понижайте частоту ретраев и общий параллелизм.
- 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 = не повторять, 5xx/таймаут = повторить.
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; для платежей — строгая дедупликация и блокировки. Меряйте ретраи и конфликты, тестируйте дубликаты и таймауты.