Idempotency и retry-storms — что должен тестировать QA в distributed systems
Самый дорогой класс багов в payment-системах — не «не прошёл платёж», а «прошёл два раза». Юзер кликнул «Оплатить» дважды, или таймаут сработал и клиент повторил, или ваша очередь доставила message не один раз — и с карты списалось дважды. Это деньги, support-тикеты, chargebacks. И каждая такая проблема — следствие непротестированной идемпотентности.
Этот гайд — что такое идемпотентность глазами QA, 5 типовых сценариев retry/duplicate которые надо тестировать, что такое retry-storm и как его поймать, конкретные инструменты (WireMock, Toxiproxy, k6) и чек-лист перед релизом payment/webhook endpoint.
Что такое идемпотентность
Операция идемпотентна, если повторный вызов с теми же параметрами даёт тот же результат и не создаёт побочных эффектов. Простой пример: GET /api/user/123 — идемпотентен (читает данные, повтор не меняет состояние). Сложный пример: POST /api/charge {amount: 100, idempotency_key: 'abc123'} — идемпотентен ЕСЛИ сервер хранит ключ и при повторе возвращает кэшированный ответ вместо нового списания.
HTTP методы по идемпотентности (RFC 7231):
GET,HEAD,OPTIONS— всегда идемпотентны (read-only).PUT,DELETE— идемпотентны по определению (заменяет/удаляет ресурс, повтор = то же состояние).POST— НЕ идемпотентен по умолчанию. Нужно делать вручную через Idempotency-Key.PATCH— зависит от семантики (если patch — относительное изменение типаbalance += 10— не идемпотентен).
Самая частая ошибка — POST без идемпотентности на платежах, формах, заказах. Stripe Idempotency-Key — канонический пример того, как это решается.
Idempotency-Key: как работает и как тестировать
Клиент генерирует уникальный ключ (UUID v4), передаёт его в заголовке Idempotency-Key при POST. Сервер при первом запросе делает операцию, сохраняет {key → response} в Redis/DB на 24 часа. При повторе с тем же ключом — возвращает сохранённый response без повторной операции.
Что QA тестирует:
- Первый POST с ключом → создаёт ресурс, возвращает 201/200.
- Повтор того же POST с тем же ключом → возвращает 200 с тем же телом, ресурс НЕ дублируется.
- Повтор того же ключа, но с ДРУГИМ телом → должен вернуть 422 или 409 (key conflict), не молча выполнить новый запрос.
- Ключ без коллизии после 24 часов TTL — можно использовать заново.
- Параллельные одновременные запросы с тем же ключом (race condition) — результат должен быть один, не два.
- Что произойдёт если key пустой / отсутствует / слишком длинный (max 255 char у Stripe)?
- Что произойдёт если ключ есть, но сервер упал между списанием и записью идемпотентности в БД?
Последний случай — самый интересный. Если процесс крашится после операции но до записи ключа, повтор приведёт к дубликату. Решение — атомарность через транзакцию или idempotency-table-first pattern.
5 типовых сценариев retry/duplicate
1. Double-submit формы
Юзер кликнул «Оплатить» → ничего не произошло за 2 секунды (просто медленная сеть) → кликнул ещё раз. Браузер отправил два POST’а. Без идемпотентности — два списания.
Как тестировать: Postman runner с двумя одинаковыми запросами почти одновременно. Дисэйблить кнопку в UI после клика — это UX, не безопасность. Серверная идемпотентность — это безопасность.
2. Client timeout retry
Клиент послал POST → ждал 30 секунд → таймаут → переотправил. Но первый запрос дошёл и обработался! Сервер вернул 200, но клиент его не дождался. Теперь два списания.
Как тестировать: Toxiproxy (github.com/Shopify/toxiproxy) — добавь latency 60s на endpoint, прогони клиент с retry-on-timeout. Должен увидеть один charge.
3. Webhook retry от third-party
Stripe / Twilio / GitHub шлют webhook → ваш endpoint вернул 500 → провайдер ретраит через 1, 5, 30 минут, 1 час, до 3 дней. Если backend всё-таки обработал первый запрос но не успел вернуть 200 — все ретраи обработаются заново.
Как тестировать: WireMock с симуляцией медленного ответа. Или просто отправь тот же payload (с тем же event id) дважды — должно обработаться один раз. На стороне backend храни event_id → processed_at, проверяй перед обработкой.
4. Queue redelivery
Kafka, SQS, RabbitMQ — все доставляют message at-least-once по умолчанию. Если consumer crashed после commit но до send_response — message доставится снова. Если consumer ack-нулся, но backend не успел записать результат — то же самое.
Как тестировать: kill consumer в середине обработки (SIGKILL во время processing). Поднимай заново. Проверь что result в БД один.
5. Cron job restart
Cron запустил «daily reconciliation» → процесс крашится через 10 минут → cron видит crash и перезапускает (если так настроен). Или Kubernetes pod retry. Без идемпотентности — пользователи получают двойные отчёты, двойные сводки, двойные начисления.
Как тестировать: запусти cron-логику дважды подряд. Результат должен быть один. Внутри cron — хранить marker «processed_at» на ресурсе.
Retry storms — как один сервис ломает соседей
Сервис A зависит от сервиса B. B на 30 секунд лёг. A получает 503 → ретраит каждые 100 мс. На пике у A в секунду 100 retry-запросов к B. Когда B встаёт обратно — он получает шторм из 30000 застрявших ретраев, не справляется, снова падает. Это cascading failure.
Канонический разбор от AWS: Timeouts, retries, and backoff with jitter. Решение — четыре техники, которые QA должен видеть и тестировать:
Exponential backoff с jitter
Ретрай не каждые 100 мс, а через 1, 2, 4, 8, 16, 32 секунды. Плюс случайная добавка (jitter) от 0 до этого значения. Это распределяет повторы во времени, не даёт thundering herd.
Retry budget
Клиент имеет лимит ретраев в минуту/час. Если сервис лежит 10 минут — после 10 ретраев клиент сдаётся, возвращает ошибку юзеру. Не лучше, но защищает от шторма.
Circuit breaker
См. Martin Fowler: CircuitBreaker. Клиент отслеживает % ошибок на сервис. При превышении threshold — «открывает» circuit, перестаёт пытаться 30 секунд (no retries at all), потом «полу-открывает» — пробует один запрос. Если успех — закрывает. Если фейл — снова блокирует.
Retry-After header
Сервер при 429 (rate limit) или 503 может вернуть Retry-After: 60 — клиент ОБЯЗАН ждать столько секунд перед ретраем. QA проверяет: клиент респектит этот header (не игнорирует)?
Что QA тестирует в retry-логике
- Клиент ретраит на 5xx, не ретраит на 4xx (4xx — клиентская ошибка, повтор бесполезен).
- Клиент НЕ ретраит на 400, 401, 403, 422 (повтор не поможет).
- Клиент ретраит на 429, но с уважением Retry-After.
- Exponential backoff действительно растёт (не fixed 100ms).
- Jitter применяется (два параллельных клиента ретраят в РАЗНЫЕ моменты).
- Max retries ограничен (например 5), не бесконечный.
- Circuit breaker открывается при N последовательных 503 и пропускает запросы.
- Тестировать на разных latency — Toxiproxy позволяет добавлять delay 100ms, 1s, 10s, dropout, slicer.
- При retry storm имитировать: 100 параллельных клиентов, backend упал — backend после восстановления НЕ должен умирать снова от шторма.
Инструменты
WireMock — fault injection
github.com/wiremock/wiremock — HTTP-mock, который можно настроить на «возвращай 503 первые 3 раза, потом 200», «возвращай random между 200 и 500», «отдавай ответ через 30 секунд». Stand-in для downstream-сервиса в тестах ретраев.
Toxiproxy — latency, dropout, slice
github.com/Shopify/toxiproxy от Shopify. TCP-proxy с injection: добавь delay, drop пакетов, slow read, timeout-эмуляция. Между клиентом и реальным сервером — для интеграционных тестов, не для unit.
k6 — load + retry storms
k6.io/docs. Js-скрипт, генерит 100-1000 виртуальных юзеров, можно настроить retry-логику. Полезно для воспроизведения retry storm — запускаешь 100 VUs которые ретраят на 503, смотришь backend metrics.
Postman / Newman runner
Для функциональных проверок идемпотентности (тот же ключ → тот же ответ). Pre-request script для генерации UUID, test-script для сравнения двух ответов.
Чек-лист перед релизом payment / webhook endpoint
- POST с Idempotency-Key возвращает тот же response при повторе с тем же ключом.
- POST с тем же ключом и ДРУГИМ телом возвращает 422/409, не молча выполняет новый запрос.
- Idempotency-key TTL документирован и тестировался (обычно 24 часа).
- Параллельные запросы с тем же ключом → один результат (race condition закрыт).
- Webhook endpoint обрабатывает один event_id один раз, даже при пересылке.
- Client retry-логика: 5xx — ретраит, 4xx (кроме 429) — не ретраит.
- Retry-After header клиент респектит.
- Exponential backoff с jitter, max 5 ретраев.
- Circuit breaker открывается при N подряд failures.
- Retry storm тест: 100 параллельных клиентов, backend down 1 минуту, восстанавливается — backend не падает снова.
- Queue consumer: kill в середине обработки → result в БД ровно один.
- Cron / scheduled job: запуск дважды подряд → результат идентичен одному.
- В CI integration tests с WireMock fault injection + Toxiproxy latency.
Идемпотентность — это не «hard problem», это «дисциплина». 90% багов с дубликатами charge / webhook / order ловятся одним unit-тестом «отправить тот же запрос дважды и проверить что эффект один». Retry storms ловятся одним integration-тестом с WireMock. Команды которые этого не делают — рано или поздно платят чарджбеками и потерей доверия пользователей за невидимые баги, которые проще всего поймать.