diff --git a/editorial/agent-rewrites/271.json b/editorial/agent-rewrites/271.json index 16369ed..b35db73 100644 --- a/editorial/agent-rewrites/271.json +++ b/editorial/agent-rewrites/271.json @@ -3,5 +3,5 @@ "slug": "editorial-2020-06-field-retry-idempotency", "title": "Потерянный ответ и повтор запроса: как не создать второй эффект", "excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.", - "contentHtml": "
Пользователь отправляет заявку, ждёт две секунды и видит timeout. Он нажимает кнопку ещё раз. Через минуту в системе появляются две заявки, два письма или два списания. В логах первой попытки уже есть 201 Created, но браузер его не получил. Цена ошибки — не только дубль. Команда тратит время на ручное удаление, пользователь не понимает, какая запись настоящая, а повторная попытка может уйти во внешнюю систему, где откат невозможен.
Timeout не доказывает, что сервер ничего не сделал. Он говорит только, что конкретный клиент не дождался события в своём лимите. Запрос мог не выйти из клиента, мог застрять в proxy, мог завершить запись после закрытия соединения или мог сохранить результат и потерять ответ на обратном пути.
\nТезис простой: безопасный retry повторяет один пользовательский intent, а не последний HTTP-пакет. Для этого сервер связывает попытки по одному Idempotency-Key, проверяет тот же значимый payload и сохраняет terminal-результат. requestId при этом меняется на каждой сетевой попытке. Если сервис не умеет отличить повтор от новой команды, автоматический retry после неизвестного исхода нужно остановить.
requestId отвечает на вопрос «какой сетевой проход мы сейчас видим?». Его создают для запроса, который прошёл через клиент, proxy и приложение. При повторе он должен быть новым. Это помогает собрать логи именно этой доставки.
Idempotency-Key отвечает на другой вопрос: «какие доставки относятся к одному действию пользователя?». Клиент сохраняет его рядом с payload с момента подтверждения формы до terminal-результата. Повтор после timeout отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, появился новый intent. Старый ключ нельзя переиспользовать для нового тела.
Заголовок не делает POST идемпотентным сам по себе. Контракт должен описать область уникальности ключа, способ сравнения тела, срок хранения записи и ответ для повтора. В одной области ключ может быть уникален для пользователя, клиента, заказа или другого владельца операции. Нельзя выбрать область по удобству таблицы: слишком широкая область блокирует чужие операции, слишком узкая пропускает дубль.
requestId показывает новую доставку. Тот же Idempotency-Key связывает её с прежним intent.Серверу нужна запись состояния операции, а не флаг seen=true. Минимальная модель содержит область ключа, сам ключ, отпечаток значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения.
Первый запрос атомарно резервирует ключ в состоянии in_progress. Только владелец резерва может выполнить бизнес-эффект. Параллельный запрос с тем же ключом не запускает обработчик второй раз: он получает документированный ответ «операция выполняется» или читает результат после завершения. Запрос с другим отпечатком получает конфликт до бизнес-эффекта.
После эффекта сервис сохраняет completed и данные, которые нужны для повторного ответа. Для операции внутри одной базы запись ключа, бизнес-объект и terminal-результат стоит зафиксировать одной транзакцией. Тогда повтор видит либо согласованный результат, либо отсутствие всей операции. Само наличие уникального индекса не заменяет обработку состояний, но защищает от гонки двух процессов.
POST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-51\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ответ потерян для клиента. Повтор:\nPOST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-52\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ожидаемый контракт учебного примера:\nHTTP/1.1 201 Created\n{\"requestId\":\"demo-request-42\",\"state\":\"accepted\"}\nЭто учебный пример. Адрес, ключ, идентификаторы и тело не относятся к production-сервису. Он проверяет только логическое свойство: один ключ и один payload дают один результат, а повтор возвращает тот же результат. Если первый запрос завершился эффектом, но ответ не дошёл до клиента, второй вызов не создаёт новую запись.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После timeout появились две записи | Повтор получил новый ключ или резерв не был атомарным | Сверить ключи, отпечатки payload и terminal-записи | Остановить blind retry; добавить уникальную границу и тест гонки |
В логах есть 201, UI показал ошибку | Результат сохранился после отправки или до потери ответа | Сопоставить время записи, response и client timeout | Повторить тем же ключом и вернуть сохранённый result ID |
| Тот же ключ пришёл с другим телом | Ключ переиспользовали для нового intent или клиент изменил payload | Сравнить canonical payload или его fingerprint | Вернуть отдельный conflict без запуска обработчика |
Повтор видит in_progress | Первая попытка ещё работает или оборвалась до terminal-записи | Проверить владельца резерва, heartbeat и дедлайн | Вернуть состояние по контракту; не удалять запись ради нового запуска |
| Внутри базы дубля нет, снаружи он есть | Внешний вызов был до сохранения terminal-состояния | Найти стабильный business ID и запись поставщика | Остановить retry до внешнего ключа или маршрута сверки результата |
Для первой проверки не нужна полноценная distributed tracing система. Достаточно безопасных полей в существующих журналах: время, маршрут, requestId, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий лог не кладут. Они расширяют риск утечки и редко помогают отличить replay от второго эффекта.
У операции должен быть общий дедлайн intent. В него входят ожидание первой попытки, пауза, допустимый retry и время, за которое интерфейс покажет неопределённый исход. Каждый новый retry не должен начинать этот бюджет заново. Иначе перегруженная зависимость получает бесконечный поток одинаковых команд.
\nНужно отдельно проверить отношения таймеров. Клиент может закрыть ожидание раньше proxy. Proxy может продолжить запрос после закрытия вкладки. Сервер может записать результат после client timeout. Это допустимые варианты доставки. Ошибка появляется там, где следующий запрос не несёт связь с первым intent и превращается в новую команду.
\nСтатус сам по себе не выбирает действие. 503 может сопровождаться Retry-After, но он не расширяет общий deadline. 4xx обычно останавливает автоматический повтор, однако плохой контракт сервера может сохранить эффект до формирования ответа. Поэтому ключ, состояние операции и журнал важнее простого списка кодов.
Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. В этом случае нельзя честно сказать «повтор безопасен». Покажите неопределённый исход, сохраните данные формы и передайте операцию в путь проверки статуса. Для платежа, заказа, письма или другой необратимой команды это безопаснее второго POST вслепую.
Внешняя система создаёт отдельную границу. Локальная транзакция не откатывает HTTP-вызов поставщику. Сервис может вызвать поставщика, получить результат, упасть до записи completed и после рестарта увидеть только in_progress. Удалить такую строку и повторить вызов — способ создать дубль. Нужен стабильный внешний идентификатор, idempotency-механизм поставщика или проверка результата по business ID.
Срок хранения ключа тоже часть контракта. Он должен покрывать максимальный retry-budget, задержки промежуточных звеньев и разрешённую задержку повторной отправки. Очищать можно terminal-записи после окончания окна. Удалять зависший in_progress без процедуры восстановления нельзя: очистка скрывает неизвестный эффект, а не устраняет его.
requestId и общий Idempotency-Key. Отсутствующее поле отметить как пробел доказательств.in_progress, completed и conflict. Для каждого состояния заранее записать машинный ответ и действие клиента.in_progress. Проверить, что уборка не запускает повторную команду.Контракт готов, если независимый инженер может по одному intent ответить на пять вопросов: какой ключ связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что происходит при неизвестном исходе. Тест с потерянным ответом показывает ровно один эффект и тот же result ID на повторе. Тест с другим payload получает conflict до эффекта. Два конкурентных запроса не создают две terminal-записи.
\nДля внешнего эффекта дополнительно существует проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если такого пути нет, готовность не подтверждена, даже если локальный unit test зелёный. Это граница механизма: идемпотентность уменьшает повтор одного intent, но не даёт гарантии exactly once между независимыми системами.
\nОператор проверяет форму создания заявки: браузер ждёт две секунды и показывает 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.