securitydistributed-systemspaymentsapitesting

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. Команды которые этого не делают — рано или поздно платят чарджбеками и потерей доверия пользователей за невидимые баги, которые проще всего поймать.