Прямой ответ
Идемпотентность — свойство операции давать тот же результат при повторном применении: в HTTP по стандарту (RFC 9110) GET/PUT/DELETE идемпотентны по контракту, POST — нет, и именно поэтому «случился таймаут — повторить POST» без защиты создаёт второй платёж. Рабочий паттерн для POST-операций — Idempotency-Key: клиент генерирует уникальный ключ на операцию (UUID на «список денег», не на попытку запроса!) и шлёт заголовком; сервер при первом обращении исполняет и сохраняет результат под ключом, при повторе с тем же ключом — возвращает сохранённый ответ, не исполняя снова (эталон реализации — Stripe API). Три детали, которые решают всё: ключ живёт на стороне клиента одна операция = один ключ (ретрай сети — тот же ключ; новая операция — новый; «один UUID на сессию» ломает идемпотентность), сервер дедуплицирует атомарно (уникальный индекс на ключ — гонка параллельных дублей не должна исполнить дважды), и окно хранения результатов (сутки — компромисс: клиент обязан успеть получить ответ; Stripe держит 24 часа). И смежная сторона — ретраи клиента: повторять безопасно только то, что идемпотентно (GET — всегда; POST — только с ключом), с экспоненциальным backoff и только на сетевых ошибках/5xx (422 «карта отклонена» ретраить бессмысленно — ответ уже окончательный).
HTTP-идемпотентность: что обещает стандарт
По RFC 9110: GET — чтение без побочных эффектов (повтор безопасен тривиально); PUT — «установить состояние X» (повторная установка того же X — тот же результат; отсюда паттерн «PUT вместо POST» для создания с клиентским id — PUT /orders/abc-123); DELETE — «удалить X» (повтор — всё ещё удалено; корректный API вернёт 204/404 без ошибки-исключения); POST — не идемпотентен (каждый вызов — новое действие: заказ, платёж, отправка) — и HEAD/OPTIONS безопасны. Практические следствия: дизайн API — создание ресурсов по возможности через PUT с клиентским идентификатором (идемпотентно без ключей); «неидемпотентный по природе» POST (платёж) — через ключ; внутренние операции — различать «установить статус оплачен» (идемпотентно: повтор не меняет) и «начислить бонусы» (неидемпотентно: повтор удваивает — нужен ключ или привязка к событию). Распространённое заблуждение: «идемпотентность = повторный запрос вернёт 200» — нет: контракт про отсутствие дополнительного побочного эффекта, а повтор DELETE может корректно вернуть 404 (ресурса уже нет) — состояние то же, эффект нулевой.
Idempotency-Key: серверная реализация
Схема (по образцу Stripe/draft-ietf-httpapi): (1) клиент шлёт Idempotency-Key: <uuid> с POST; (2) сервер до исполнения пытается занять ключ (INSERT в idempotency_keys с уникальным индексом — успех = мы первые; конфликт = кто-то уже исполняет/исполнил); (3) первый исполнитель — работает, сохраняет полный ответ (статус, тело, заголовки) в строке ключа; (4) повтор с тем же ключом — возврат сохранённого ответа (клиент не отличает «первый раз» от «повтора» — в этом смысл); (5) запрос в процессе исполнения пришедший повторно — 409/ожидание (не исполнять параллельно — гонка); (6) разные тела при одном ключе — 422 (ключ переиспользован под другую операцию — ошибка клиента, не молчаливый возврат старого). Хранение — TTL сутки-неделя (после — ключ свободен: «платёж месячной давности тем же UUID» — новый платёж; клиентские системы это понимают). Где жить ключу: уникальная таблица рядом с БД операций (атомарность занятия и результата — в одной транзакции с бизнес-записью, где возможно); Redis-вариант быстрее, но переживание рестарта кеша важнее скорости для денег (выбор хранилища).
Ретраи клиента: когда повторять можно и нужно
Дисциплина ретраев — половина идемпотентности (сервер защищает от дублей, клиент — от лавин). Что ретраить: сетевые ошибки (connection reset/timeout — запрос мог не дойти ИЛИ дойти и исполниться: без ключа повтор POST = риск дубля), 502/503/504 (прокси/сервер не справились — исполнение под вопросом), 429 с Retry-After (лимиты — уважать указанную паузу). Что не ретраить: 4xx кроме 429 (400/422 — ошибка в запросе, повтор не исправит; 401/403 — проблемы доступа), и таймауты без ключа на POST. Как: экспоненциальный backoff с джиттером (1s, 2s, 4s + случайность — синхронные ретраи тысяч клиентов роняют восстановившийся сервер — retry storm), лимит попыток (3–5), и идемпотентный ключ на каждый POST («новый ключ на ретрай» — уничтожение смысла; «тот же ключ на новую операцию» — ложная дедупликация: генерировать ключ в момент создания операции, до первой попытки). Для фоновых задач (Celery — практика) — те же правила: autoretry_for на сетевые исключения + идемпотентное тело задачи (повторное исполнение воркером — норма at-least-once).
Практический сценарий: платёжная операция без двойных списаний
Флоу: мобильное приложение → API → платёжный провайдер. Реализация: приложение создаёт операцию — локальная запись payment_intent с UUID до запроса (ключ = этот UUID, сохраняется с операцией — ретраи приложения после краша шлют тот же); API — POST /payments с Idempotency-Key: занять ключ → вызвать провайдера (со своим идемпотентным ключом — двойная защита: наш ретрай к провайдеру тоже безопасен) → сохранить ответ. Инцидент-тест (проверяется до прода, не после): на запросе рвётся сеть между приложением и API — платёж у провайдера создался; приложение ретраит с тем же ключом через 3 секунды — API видит занятый ключ, дожидается/возвращает сохранённый результат — клиент получил «оплачено» один раз, списание одно. Контр-инцидент (найден на тесте): первая реализация генерировала UUID в http-клиенте на каждую попытку — ретрай создал бы второй платёж — исправлено переносом генерации в слой операции. Смежный контур — вебхук от провайдера (приём): его обработчик тоже идемпотентен (повторная доставка payment.succeeded — статус не меняется, письмо не дублируется). Итог: тройная идемпотентность (клиентский ключ, серверная дедупликация, идемпотентный обработчик события) — любой сбой сети в любой точке не удваивает деньги.
FAQ
Достаточно ли уникального индекса в БД вместо ключей?
Для «создать сущность» — часто да: INSERT ... ON CONFLICT с бизнес-ключом (номер заказа от клиента) даёт идемпотентность создания без отдельной таблицы; для операций без естественного ключа (платёж по существующему заказу — два платежа легитимны) и для возврата сохранённого ответа — нужен явный Idempotency-Key.
Что делать с GET, который что-то меняет (антипаттерн)? Исправить API: побочные эффекты — в POST/PUT (GET с эффектами ломает кеширование, prefetch браузера и ретраи — прокси-кеш может дёрнуть его сам); пока не исправлено — не кешировать, не ретраить автоматически, считать опасным.
Как тестировать идемпотентность? Юнит: повторный вызов с тем же ключом — результат идентичен, состояние не изменилось; интеграция: параллельная отправка 10 одинаковых запросов — одна операция (гонка занята индексом); сетевой уровень: имитация таймаута после исполнения (запрос исполнен, ответ потерян) — ретрай возвращает сохранённый ответ. Три теста покрывают основные векторы дублей.
Идемпотентность и очереди сообщений — связаны? Напрямую: at-least-once доставка очередей (гарантии) означает повторы обработок — обработчик сообщения обязан быть идемпотентным (тот же паттерн: уникальность по id события); «exactly-once» в распределённых системах — иллюзия, рабочая связка всегда «доставка с повторами + идемпотентная обработка».
Источники: RFC 9110, Stripe idempotent requests, draft Idempotency-Key header — ссылки в шапке.