{ "index": 271, "slug": "editorial-2020-06-field-retry-idempotency", "title": "Потерянный ответ и повтор запроса: как не создать второй эффект", "excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.", "contentHtml": "
Оператор проверяет форму создания заявки: браузер ждёт две секунды и показывает timeout. В журнале клиента остаётся requestId=req-51, но статуса нет. Пользователь нажимает кнопку ещё раз, а через минуту в системе появляются две заявки или два письма. Цена ошибки — не только дубль: приходится искать и удалять лишнюю запись, а повтор команды мог уже уйти во внешнюю систему без обратного вызова.
Сначала оператор предполагает, что сервер не получил первый запрос. Он отправляет его снова и получает 201 Created. Проверка журнала меняет картину: первый обработчик сохранил запись, но ответ потерялся между сервером и браузером. Значит, timeout не доказывает отсутствие эффекта. Без связи между попытками второй POST становится новой командой, поэтому безопасный retry должен повторять один пользовательский intent, а не последний сетевой пакет.
В учебной истории первый запрос ушёл на POST /v1/demo-requests с ключом demo-key-retry-2020-06-a7f1 и получил новый requestId=req-51. Сервер записал результат demo-request-42, но соединение закрылось до того, как клиент прочитал ответ. Через несколько секунд браузер создал второй requestId=req-52. Если вместе с ним отправить новый ключ, сервер не отличит повтор от новой заявки.
Затем оператор сравнивает не только статусы, но и два идентификатора. Разные requestId показывают две доставки, а один Idempotency-Key связывает их с одним намерением пользователя. Это не готовая гарантия: сервер ещё должен хранить запись ключа, сравнивать значимый payload и вернуть сохранённый terminal-результат. В этот момент исходная гипотеза «первый запрос не дошёл» уже не объясняет наблюдение.
После этого оператор повторяет запрос с тем же ключом и тем же телом на учебном стенде. Сервис возвращает исходный результат, а счётчик эффектов остаётся равным одному. Повтор с изменённым полем получает конфликт до обработчика. Так расследование заканчивается ограниченным выводом: replay защищён только в пределах описанного scope, срока хранения и контракта сравнения данных.
\nrequestId — идентификатор конкретной доставки через клиент, proxy или приложение. Его область и момент создания задаёт ваш контракт наблюдаемости; сам HTTP не требует именно такого заголовка. При повторе сетевой попытки он обычно меняется, чтобы логи не смешивали два прохода.
Idempotency-Key отвечает на другой вопрос: какие доставки относятся к одному действию пользователя? Клиент создаёт его до первой попытки и сохраняет до terminal-результата. Повтор после неизвестного исхода отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, начинается новый intent с новым ключом.
Заголовок сам по себе не делает POST идемпотентным. В API-контракте нужно зафиксировать scope ключа, обязательность заголовка, способ вычисления fingerprint, срок хранения записи, ответы для повтора и поведение при несовпадении payload. Слишком широкая область заблокирует операции разных владельцев, слишком узкая пропустит дубль.
requestId обозначает новую доставку. Общий Idempotency-Key связывает её с прежним intent.Серверу нужна устойчивая запись состояния, а не флаг seen=true. Минимальная запись содержит scope, ключ, fingerprint значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения. Резерв ключа должен появляться атомарно: два процесса не могут одновременно получить право выполнить один эффект.
Первый запрос создаёт запись in_progress. Только владелец резерва запускает бизнес-операцию. Параллельный запрос с тем же ключом ждёт, получает документированный ответ «операция выполняется» или читает результат после завершения — конкретное поведение выбирает API. Запрос с тем же ключом и другим fingerprint получает конфликт до бизнес-обработчика.
После эффекта сервис сохраняет completed и данные, нужные для replay. Если объект и запись ключа находятся в одной базе, их можно зафиксировать одной транзакцией. Это защищает внутреннюю границу: повтор увидит либо согласованный результат, либо отсутствие всей операции. Внешний HTTP-вызов в такую транзакцию не входит, и уникальный индекс не устраняет этот разрыв.
| Симптом | Гипотеза | Проверка | Ограниченное действие |
|---|---|---|---|
| После timeout появились две записи | Новый ключ или неатомарный резерв | Сверить ключи, fingerprint и записи эффекта | Остановить blind retry; проверить уникальную границу и гонку |
В журнале есть 201, UI показал ошибку | Результат сохранился, ответ не дошёл | Сопоставить время записи, response и client timeout | Повторить тем же ключом и вернуть сохранённый result ID |
| Тот же ключ пришёл с другим телом | Ключ переиспользован для новой команды | Сравнить canonical payload или fingerprint | Вернуть конфликт до бизнес-эффекта |
Повтор видит in_progress | Первая попытка ещё работает или оборвалась | Проверить владельца резерва, heartbeat и дедлайн | Следовать контракту состояния; не удалять запись вслепую |
| В базе дубля нет, снаружи он есть | Внешний вызов был до terminal-записи | Найти внешний business ID и результат поставщика | Остановить retry до status route или внешнего key |
Ниже — маленький Node.js-пример без сети и базы. Он показывает только контракт повторного чтения: первый вызов создаёт эффект, второй с тем же ключом возвращает сохранённый ответ, а другое тело получает 409. В production Map нельзя считать защитой от двух процессов: там нужны устойчивое хранилище, атомарная операция и политика восстановления.
const records = new Map(); let effects = 0; function handle(key, payload) { const fingerprint = JSON.stringify(payload); const saved = records.get(key); if (saved && saved.fingerprint !== fingerprint) return { status: 409, error: 'payload_mismatch' }; if (saved) return { status: saved.status, body: saved.body }; effects += 1; const result = { requestId: 'demo-request-42', state: 'accepted' }; records.set(key, { fingerprint, status: 201, body: result }); return { status: 201, body: result }; } const first = handle('demo-key-retry-2020-06-a7f1', { topic: 'bundle review' }); const replay = handle('demo-key-retry-2020-06-a7f1', { topic: 'bundle review' }); const conflict = handle('demo-key-retry-2020-06-a7f1', { topic: 'other command' }); console.log({ effects, first, replay, conflict });\nОжидаемый учебный вывод содержит effects: 1, одинаковый requestId в first и replay, а в conflict — status: 409. Значения ключа, темы и идентификатора придуманы для примера. Функция не моделирует потерю ответа, конкурентный доступ, TTL или внешний эффект; это нужно проверять отдельными тестами.
Чтобы приблизить пример к HTTP-трассе, первая доставка может содержать X-Request-Id: req-51, а повтор — X-Request-Id: req-52. В обоих запросах остаются Idempotency-Key: demo-key-retry-2020-06-a7f1 и одинаковое значимое тело. Если второй запрос изменит поле, правильный результат — конфликт, а не новый вызов обработчика.
Для расследования достаточно безопасных полей: время, маршрут, requestId, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий журнал не кладут. Иначе диагностика превращается в новый канал утечки, а повтор всё равно нельзя будет доказать одним логом.
У intent должен быть общий deadline. В него входят ожидание первой попытки, пауза, разрешённое число повторов и время, после которого интерфейс показывает неопределённый исход. Каждый retry использует остаток этого бюджета. Новый таймер на каждом уровне может превратить временный сбой зависимости в бесконечный поток одинаковых команд.
\nСравните таймеры клиента, proxy и сервера. Клиент может прекратить ожидание раньше proxy, proxy — продолжить запрос после закрытия вкладки, а сервер — записать результат после client timeout. Это допустимые варианты доставки. Причина дубля появляется, когда следующая попытка не связана с первым intent или когда ключ уже удалён до разрешённого окна replay.
\n503 может сопровождаться Retry-After, но этот заголовок не сообщает, что повтор записи безопасен. 4xx часто останавливает автоматический retry, однако статус сам по себе не доказывает отсутствие эффекта у плохо определённого API. Действие выбирают по семантике операции, состоянию ключа и возможности прочитать результат.
Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. После неизвестного исхода покажите пользователю нейтральное состояние, сохраните форму и передайте команду в маршрут проверки статуса. Для платежа, заказа, письма или другой необратимой операции это безопаснее второго POST вслепую.
Локальная транзакция не откатывает HTTP-вызов поставщику. Сервис может вызвать внешнюю систему, получить её ответ, упасть до записи completed и после рестарта увидеть только in_progress. Удалить строку и повторить вызов — способ создать дубль. Нужен стабильный внешний идентификатор, поддержка idempotency у поставщика или чтение результата по business ID.
Срок хранения ключа — часть контракта, а не уборка по удобству. Он должен покрывать retry-budget, задержки промежуточных звеньев и разрешённую задержку повторной отправки. Terminal-записи можно очищать после окна хранения. Зависший in_progress сначала проходит процедуру сверки или восстановления; простое удаление скрывает неизвестный эффект.
requestId и проверьте, что повторы используют общий Idempotency-Key.in_progress, completed и mismatch. Для каждого состояния заранее запишите машинный ответ и действие клиента.Контракт готов, если независимый инженер по одному intent может ответить на пять вопросов: какой scope связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что делать при неизвестном исходе. Тест потерянного ответа показывает ровно один эффект и тот же result ID. Тест другого payload получает конфликт до эффекта. Два конкурентных запроса не создают две terminal-записи.
\nДля внешнего эффекта дополнительно нужен проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если его нет, готовность не подтверждена, даже если локальный тест зелёный. Идемпотентность связывает повтор одного intent, но не даёт гарантии exactly once между независимыми системами. Возвращаясь к учебному случаю: оператор доказал не то, что timeout безопасен, а то, что конкретный key, scope, payload и срок хранения дали один наблюдаемый результат.
\nIdempotency-Key.