{ "index": 272, "slug": "editorial-2020-06-mechanism-retry-idempotency", "title": "Идемпотентный retry: как не выполнить одну команду дважды", "excerpt": "Клиент получил timeout, но сервер мог уже создать результат. Разбираем scope ключа, fingerprint payload, конкурентный запрос, сохранённый ответ и границу внешнего эффекта.", "contentHtml": "

Симптом знакомый: пользователь нажал «Создать», увидел timeout и нажал ещё раз. В базе появились две заявки. Иногда дубль возникает без второго клика: клиент повторяет POST после разрыва соединения, пока первый обработчик ещё работает. Цена ошибки зависит от операции. Две строки в черновике можно удалить. Два платежа, письма или заказа требуют ручного разбора и возврата денег.

\n

Timeout сообщает только об ответе, которого клиент не увидел. Он не доказывает, что сервер не принял запрос. Между принятием команды и доставкой ответа есть база, worker, proxy и сеть. Любой участок может завершить работу, а следующий участок — потерять результат.

\n

Тезис статьи простой: retry безопасен только тогда, когда сервис связывает повтор с тем же intent и хранит решение операции. Для этого нужны scope ключа, fingerprint значимых данных, атомарный захват ключа, состояние in_progress и сохранённый terminal-ответ. Повтор не должен снова запускать бизнес-обработчик.

\n

Что именно делает идемпотентность

\n

Идемпотентность не означает «ответ всегда одинаковый» и не означает «в системе не будет побочных эффектов». В HTTP это свойство намеренного эффекта метода: несколько одинаковых запросов должны дать тот же эффект, что один. RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы. POST сам по себе таким свойством не обладает.

\n

Для POST сервис может добавить собственный контракт. Клиент создаёт ключ на один пользовательский intent и сохраняет его до получения окончательного результата. При сетевом сбое он отправляет тот же payload с тем же ключом. Сервер узнаёт повтор, возвращает прежний результат и не создаёт новый объект.

\n

Ключ относится к действию, а не к сетевой попытке. requestId может меняться на каждом HTTP-проходе. Idempotency-Key должен оставаться тем же для одного intent. Если на retry сгенерировать новый ключ, сервер увидит новую команду и защита не сработает.

\n

Scope и fingerprint

\n

Один ключ не обязан быть уникальным во всей системе. Сервис задаёт scope. В учебном примере это actor_id, имя операции и idem_key. Одинаковая строка ключа у двух пользователей не должна открыть один результат. Одинаковая строка в другой операции не должна заблокировать её.

\n

Одного scope мало. Клиент может по ошибке повторно использовать ключ с другим payload. Поэтому при первом запросе сервис вычисляет fingerprint по каноническим значимым полям. Повтор с тем же ключом и тем же fingerprint — кандидат на replay. Тот же ключ с другим fingerprint — конфликт. Обработчик не запускается.

\n

Канонизация входит в API-контракт. Нужно заранее определить, какие поля влияют на эффект. Порядок ключей JSON, пробелы и display-поля не должны случайно превращать тот же intent в другой. Поля авторизации, которые задают scope, не смешивают с payload fingerprint: субъект проверяется отдельно.

\n
\"Диаграмма
Ключ связывает scope, fingerprint и одно решение. Он не кэширует успех вообще: он защищает конкретный intent.
\n

Состояния записи

\n

Минимальная запись хранит scope, fingerprint, состояние, срок действия и данные replay. Для terminal-состояния достаточно статуса, безопасного тела ответа и идентификатора результата. Сохранять в этой таблице токены, cookies и полный запрос не нужно. Слишком бедная запись тоже опасна: если в ней нет результата, повтор снова вынужден угадывать.

\n
Что нужно сохранить для одного ключа
ПолеЗадачаЧто проверить
actor_id + operation + idem_keyОписывает scope и захватывает intentВ базе есть составное уникальное ограничение
payload_hashОтличает retry от подмены payloadЗначимое изменение даёт конфликт до обработчика
stateРазделяет in_progress и terminalПараллельный запрос не начинает второй handler
status_code + response_bodyВозвращает тот же наблюдаемый результатГотовый retry получает тот же result ID
expires_atЗадаёт окно повторного распознаванияTTL длиннее согласованного retry budget
\n

Сервис не должен считать запись seen=true достаточной. Такой флаг не отвечает на три вопроса: обработчик ещё выполняется, операция завершилась ошибкой или результат уже сохранён? Разные ответы требуют разных действий. in_progress нельзя молча трактовать как новый запуск.

\n

Атомарный захват в хранилище

\n

Проверка в памяти процесса не защищает второй pod, worker или рестарт. Уникальность должна жить в устойчивом хранилище, где конкурирующие запросы видят один результат. Первый запрос вставляет запись в scope. Только тот, кто успешно вставил строку, получает право вызвать бизнес-обработчик.

\n
BEGIN;\n\nINSERT INTO idempotency_keys (\n  actor_id, operation, idem_key, payload_hash, state, expires_at\n) VALUES ($1, $2, $3, $4, 'in_progress', $5)\nON CONFLICT (actor_id, operation, idem_key) DO NOTHING;\n\n-- если вставлена строка: этот запрос владеет обработкой\n-- если строка уже была: читаем state и payload_hash\n\nCOMMIT;
\n

SQL — учебная иллюстрация, а не готовая схема для конкретного проекта. Драйвер должен надёжно различать вставку и конфликт. После конфликта сервис читает существующую запись и выбирает ветку. Он не вызывает основной обработчик до этого решения.

\n

Четыре наблюдаемые ветки

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Первый запрос вставил записьКлюч свободен в заданном scopeТекущая транзакция получила право обработкиЗапустить один handler и завершить запись terminal-результатом
Повтор видит completedПервый запрос уже сохранил решениеЕсть status, body и result IDВернуть сохранённый HTTP-ответ без нового эффекта
Повтор видит in_progressПервый handler ещё не завершил договорTerminal-записи нет, ключ занятВернуть документированный conflict или статус ожидания; не запускать второй handler
Тот же ключ имеет другой hashКлюч повторно использовали для другого intentСравнить hash до business actionВернуть 409 Conflict с машинной причиной; попросить новый intent
Два result ID имеют один scope/keyЗахват неатомарен или часть writers обошла контрактСопоставить SQL, pod, время и внешние вызовыОстановить blind retry и исправить общую границу конкурентного доступа
\n

Для in_progress API выбирает одну семантику и документирует её. Можно вернуть конфликт с полем code=operation_in_progress. Можно дать клиенту endpoint проверки статуса. Нельзя скрыть этот случай за обычным retry без ключа: он снова создаст гонку.

\n

Сохраняем terminal-ответ

\n

Обработчик должен завершить запись тем результатом, который увидит клиент. Если объект и запись ключа лежат в одной базе, их можно сохранить одной транзакцией. После commit повтор найдёт оба факта и вернёт status и result ID. Если процесс упал до commit, следующий запрос увидит незавершённую транзакцию и сможет начать обработку по правилам базы.

\n
async function createOnce(input, key, actorId) {\n  const hash = fingerprint(input);\n  const claim = await reserve(actorId, 'demo.create', key, hash);\n\n  if (claim.kind === 'replay') return claim.response;\n  if (claim.kind === 'conflict') throw new HttpError(409, 'key_payload_mismatch');\n  if (claim.kind === 'in_progress') throw new HttpError(409, 'operation_in_progress');\n\n  try {\n    const result = await createResultAndFinishAtomically(input, claim.id);\n    return result.response;\n  } catch (error) {\n    await markTerminalFailure(claim.id, safeError(error));\n    throw error;\n  }\n}
\n

Псевдокод показывает порядок, но не обещает конкретный статус ошибки, библиотеку базы или способ восстановления. На практике нужно решить, какие ошибки считаются terminal. Если обработчик не стартовал из-за валидации, ключ можно не резервировать или сохранить отказ по отдельному контракту. Если внешний эффект уже принят, удалять запись после исключения опасно.

\n

Внешняя система ломает локальную атомарность

\n

Локальная таблица не делает внешний вызов exactly once. Сервис может отправить запрос поставщику, получить результат, а затем упасть до сохранения completed. После рестарта запись выглядит зависшей, хотя внешний объект уже создан. Очистка ключа и новый вызов могут создать дубль.

\n

Для внешнего эффекта нужен второй договор. Используйте устойчивый business ID, детерминированное имя объекта, idempotency key поставщика или endpoint проверки состояния. Восстановление in_progress должно сначала узнать, был ли эффект принят, и только потом решать, допустим ли повтор. Если такой проверки нет, автоматический retry нужно остановить и передать неопределённый исход в безопасный маршрут.

\n

Это отрицательный путь механизма. Идемпотентность не отменяет уже отправленное письмо и не откатывает платёж в другой системе. Она лишь даёт локальную запись, с которой можно продолжить расследование. Граница транзакции должна быть явно отмечена в архитектуре.

\n

Порядок проверки

\n
  1. Запишите один intent: субъект, операцию, значимые поля payload и допустимое окно retry.
  2. Разделите идентификаторы: новый requestId для каждой доставки, один Idempotency-Key для одного intent.
  3. Определите канонизацию и вычислите fingerprint. Проверьте, что изменение значимого поля даёт conflict до эффекта.
  4. Добавьте составное уникальное ограничение в устойчивое хранилище. Запустите два конкурентных запроса с одним scope и одним ключом.
  5. Проверьте все четыре ветки: claim, replay, in-progress и payload mismatch. Для каждой зафиксируйте HTTP-статус и машинную причину.
  6. Сымитируйте потерю ответа после сохранения результата. Повторите тот же payload с тем же ключом и подтвердите один result ID.
  7. Перезапустите процесс после начала внешнего вызова. Проверьте статус внешнего эффекта до любого повторного вызова.
  8. Проверьте TTL только на terminal-записях. Убедитесь, что поздний повтор после очистки явно запрещён или создаёт новый intent по документации.
\n

Ограничения

\n

Ключ не заменяет аутентификацию, авторизацию, валидацию, rate limit и защиту от перегрузки. Он не спасает, если разные writers используют разные scope или один путь вызывает внешний эффект до резервирования записи. Он также не гарантирует exactly once между двумя независимыми системами.

\n

Нельзя выбрать TTL по удобству таблицы. Он должен учитывать максимальный retry budget клиента, задержку proxy и время, после которого API запрещает поздний повтор. Слишком короткий TTL превращает поздний retry в новый эффект. Слишком длинный TTL удерживает результат и чувствительные данные без необходимости.

\n

Все адреса, SQL, ключи, result ID и ответы ниже учебные. Реальный endpoint, база, proxy, нагрузка и платёжная интеграция здесь не запускались. Перед внедрением нужно проверить конкурентные транзакции, размер response body, правила хранения данных и поведение каждого внешнего поставщика.

\n

Критерий готовности

\n

Механизм готов, когда два конкурентных запроса с одним scope и ключом создают один effect; повтор после искусственно потерянного ответа возвращает сохранённый result ID; другой payload с тем же ключом получает conflict до обработчика; зависший внешний вызов имеет отдельный статусный маршрут; а TTL не открывает неоговорённый поздний retry. Эти условия должны быть проверены интеграционным тестом в выбранном стеке и видны в безопасных логах.

\n

Проверяемые источники

\n" }