Files

8 lines
22 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 271,
"slug": "editorial-2020-06-field-retry-idempotency",
"title": "Потерянный ответ и повтор запроса: как не создать второй эффект",
"excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.",
"contentHtml": "<p>Оператор проверяет форму создания заявки: браузер ждёт две секунды и показывает timeout. В журнале клиента остаётся <code>requestId=req-51</code>, но статуса нет. Пользователь нажимает кнопку ещё раз, а через минуту в системе появляются две заявки или два письма. Цена ошибки — не только дубль: приходится искать и удалять лишнюю запись, а повтор команды мог уже уйти во внешнюю систему без обратного вызова.</p>\n<p>Сначала оператор предполагает, что сервер не получил первый запрос. Он отправляет его снова и получает <code>201 Created</code>. Проверка журнала меняет картину: первый обработчик сохранил запись, но ответ потерялся между сервером и браузером. Значит, timeout не доказывает отсутствие эффекта. Без связи между попытками второй <code>POST</code> становится новой командой, поэтому безопасный retry должен повторять один пользовательский intent, а не последний сетевой пакет.</p>\n<h2>Как развивался случай</h2>\n<p>В учебной истории первый запрос ушёл на <code>POST /v1/demo-requests</code> с ключом <code>demo-key-retry-2020-06-a7f1</code> и получил новый <code>requestId=req-51</code>. Сервер записал результат <code>demo-request-42</code>, но соединение закрылось до того, как клиент прочитал ответ. Через несколько секунд браузер создал второй <code>requestId=req-52</code>. Если вместе с ним отправить новый ключ, сервер не отличит повтор от новой заявки.</p>\n<p>Затем оператор сравнивает не только статусы, но и два идентификатора. Разные <code>requestId</code> показывают две доставки, а один <code>Idempotency-Key</code> связывает их с одним намерением пользователя. Это не готовая гарантия: сервер ещё должен хранить запись ключа, сравнивать значимый payload и вернуть сохранённый terminal-результат. В этот момент исходная гипотеза «первый запрос не дошёл» уже не объясняет наблюдение.</p>\n<p>После этого оператор повторяет запрос с тем же ключом и тем же телом на учебном стенде. Сервис возвращает исходный результат, а счётчик эффектов остаётся равным одному. Повтор с изменённым полем получает конфликт до обработчика. Так расследование заканчивается ограниченным выводом: replay защищён только в пределах описанного scope, срока хранения и контракта сравнения данных.</p>\n<h2>Попытка и intent — разные идентификаторы</h2>\n<p><code>requestId</code> — идентификатор конкретной доставки через клиент, proxy или приложение. Его область и момент создания задаёт ваш контракт наблюдаемости; сам HTTP не требует именно такого заголовка. При повторе сетевой попытки он обычно меняется, чтобы логи не смешивали два прохода.</p>\n<p><code>Idempotency-Key</code> отвечает на другой вопрос: какие доставки относятся к одному действию пользователя? Клиент создаёт его до первой попытки и сохраняет до terminal-результата. Повтор после неизвестного исхода отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, начинается новый intent с новым ключом.</p>\n<p>Заголовок сам по себе не делает <code>POST</code> идемпотентным. В API-контракте нужно зафиксировать scope ключа, обязательность заголовка, способ вычисления fingerprint, срок хранения записи, ответы для повтора и поведение при несовпадении payload. Слишком широкая область заблокирует операции разных владельцев, слишком узкая пропустит дубль.</p>\n<figure><img src='/assets/editorial/2020/retry-idempotency-diagnosis-2020.svg' alt='Временная схема: первая попытка сохраняет результат, ответ теряется, а повтор с тем же ключом получает сохранённый результат без второго эффекта' loading='lazy' /><figcaption>Новый <code>requestId</code> обозначает новую доставку. Общий <code>Idempotency-Key</code> связывает её с прежним intent.</figcaption></figure>\n<h2>Состояния: резерв, эффект, replay</h2>\n<p>Серверу нужна устойчивая запись состояния, а не флаг <code>seen=true</code>. Минимальная запись содержит scope, ключ, fingerprint значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения. Резерв ключа должен появляться атомарно: два процесса не могут одновременно получить право выполнить один эффект.</p>\n<p>Первый запрос создаёт запись <code>in_progress</code>. Только владелец резерва запускает бизнес-операцию. Параллельный запрос с тем же ключом ждёт, получает документированный ответ «операция выполняется» или читает результат после завершения — конкретное поведение выбирает API. Запрос с тем же ключом и другим fingerprint получает конфликт до бизнес-обработчика.</p>\n<p>После эффекта сервис сохраняет <code>completed</code> и данные, нужные для replay. Если объект и запись ключа находятся в одной базе, их можно зафиксировать одной транзакцией. Это защищает внутреннюю границу: повтор увидит либо согласованный результат, либо отсутствие всей операции. Внешний HTTP-вызов в такую транзакцию не входит, и уникальный индекс не устраняет этот разрыв.</p>\n<div class='table-scroll'><table><caption>Как читать симптом повтора</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Гипотеза</th><th scope='col'>Проверка</th><th scope='col'>Ограниченное действие</th></tr></thead><tbody><tr><td>После timeout появились две записи</td><td>Новый ключ или неатомарный резерв</td><td>Сверить ключи, fingerprint и записи эффекта</td><td>Остановить blind retry; проверить уникальную границу и гонку</td></tr><tr><td>В журнале есть <code>201</code>, UI показал ошибку</td><td>Результат сохранился, ответ не дошёл</td><td>Сопоставить время записи, response и client timeout</td><td>Повторить тем же ключом и вернуть сохранённый result ID</td></tr><tr><td>Тот же ключ пришёл с другим телом</td><td>Ключ переиспользован для новой команды</td><td>Сравнить canonical payload или fingerprint</td><td>Вернуть конфликт до бизнес-эффекта</td></tr><tr><td>Повтор видит <code>in_progress</code></td><td>Первая попытка ещё работает или оборвалась</td><td>Проверить владельца резерва, heartbeat и дедлайн</td><td>Следовать контракту состояния; не удалять запись вслепую</td></tr><tr><td>В базе дубля нет, снаружи он есть</td><td>Внешний вызов был до terminal-записи</td><td>Найти внешний business ID и результат поставщика</td><td>Остановить retry до status route или внешнего key</td></tr></tbody></table></div>\n<h2>Учебный пример, который можно повторить</h2>\n<p>Ниже — маленький Node.js-пример без сети и базы. Он показывает только контракт повторного чтения: первый вызов создаёт эффект, второй с тем же ключом возвращает сохранённый ответ, а другое тело получает <code>409</code>. В production <code>Map</code> нельзя считать защитой от двух процессов: там нужны устойчивое хранилище, атомарная операция и политика восстановления.</p>\n<pre><code>const records = new Map(); let effects = 0; function handle(key, payload) { const fingerprint = JSON.stringify(payload); const saved = records.get(key); if (saved &amp;&amp; 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 });</code></pre>\n<p>Ожидаемый учебный вывод содержит <code>effects: 1</code>, одинаковый <code>requestId</code> в <code>first</code> и <code>replay</code>, а в <code>conflict</code> — <code>status: 409</code>. Значения ключа, темы и идентификатора придуманы для примера. Функция не моделирует потерю ответа, конкурентный доступ, TTL или внешний эффект; это нужно проверять отдельными тестами.</p>\n<p>Чтобы приблизить пример к HTTP-трассе, первая доставка может содержать <code>X-Request-Id: req-51</code>, а повтор — <code>X-Request-Id: req-52</code>. В обоих запросах остаются <code>Idempotency-Key: demo-key-retry-2020-06-a7f1</code> и одинаковое значимое тело. Если второй запрос изменит поле, правильный результат — конфликт, а не новый вызов обработчика.</p>\n<h2>Логи и временной бюджет</h2>\n<p>Для расследования достаточно безопасных полей: время, маршрут, <code>requestId</code>, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий журнал не кладут. Иначе диагностика превращается в новый канал утечки, а повтор всё равно нельзя будет доказать одним логом.</p>\n<p>У intent должен быть общий deadline. В него входят ожидание первой попытки, пауза, разрешённое число повторов и время, после которого интерфейс показывает неопределённый исход. Каждый retry использует остаток этого бюджета. Новый таймер на каждом уровне может превратить временный сбой зависимости в бесконечный поток одинаковых команд.</p>\n<p>Сравните таймеры клиента, proxy и сервера. Клиент может прекратить ожидание раньше proxy, proxy — продолжить запрос после закрытия вкладки, а сервер — записать результат после client timeout. Это допустимые варианты доставки. Причина дубля появляется, когда следующая попытка не связана с первым intent или когда ключ уже удалён до разрешённого окна replay.</p>\n<h2>Статус не заменяет контракт</h2>\n<p><code>503</code> может сопровождаться <code>Retry-After</code>, но этот заголовок не сообщает, что повтор записи безопасен. <code>4xx</code> часто останавливает автоматический retry, однако статус сам по себе не доказывает отсутствие эффекта у плохо определённого API. Действие выбирают по семантике операции, состоянию ключа и возможности прочитать результат.</p>\n<p>Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. После неизвестного исхода покажите пользователю нейтральное состояние, сохраните форму и передайте команду в маршрут проверки статуса. Для платежа, заказа, письма или другой необратимой операции это безопаснее второго <code>POST</code> вслепую.</p>\n<h2>Граница внешней системы</h2>\n<p>Локальная транзакция не откатывает HTTP-вызов поставщику. Сервис может вызвать внешнюю систему, получить её ответ, упасть до записи <code>completed</code> и после рестарта увидеть только <code>in_progress</code>. Удалить строку и повторить вызов — способ создать дубль. Нужен стабильный внешний идентификатор, поддержка idempotency у поставщика или чтение результата по business ID.</p>\n<p>Срок хранения ключа — часть контракта, а не уборка по удобству. Он должен покрывать retry-budget, задержки промежуточных звеньев и разрешённую задержку повторной отправки. Terminal-записи можно очищать после окна хранения. Зависший <code>in_progress</code> сначала проходит процедуру сверки или восстановления; простое удаление скрывает неизвестный эффект.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Выберите один intent и запишите его scope, значимый payload, владельца и общий deadline.</li><li>Найдите для каждой доставки отдельный <code>requestId</code> и проверьте, что повторы используют общий <code>Idempotency-Key</code>.</li><li>Зафиксируйте canonical payload и fingerprint. Изменение значимого поля должно привести к конфликту до бизнес-обработчика.</li><li>Проверьте атомарный резерв одним ключом для двух конкурентных запросов. Только один запрос получает право выполнить эффект.</li><li>Смоделируйте потерю ответа после сохранения результата. Replay должен вернуть согласованный статус и тот же result ID, а число эффектов — остаться равным одному.</li><li>Проверьте состояния <code>in_progress</code>, <code>completed</code> и mismatch. Для каждого состояния заранее запишите машинный ответ и действие клиента.</li><li>Проверьте TTL относительно retry-budget. Отдельно зафиксируйте процедуру восстановления зависшего резерва; не превращайте очистку в повтор команды.</li><li>Если эффект внешний, остановите автоматический retry и найдите внешний ключ или маршрут чтения результата. Локальная запись не является доказательством внешнего успеха.</li></ol>\n<h2>Критерий готовности</h2>\n<p>Контракт готов, если независимый инженер по одному intent может ответить на пять вопросов: какой scope связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что делать при неизвестном исходе. Тест потерянного ответа показывает ровно один эффект и тот же result ID. Тест другого payload получает конфликт до эффекта. Два конкурентных запроса не создают две terminal-записи.</p>\n<p>Для внешнего эффекта дополнительно нужен проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если его нет, готовность не подтверждена, даже если локальный тест зелёный. Идемпотентность связывает повтор одного intent, но не даёт гарантии exactly once между независимыми системами. Возвращаясь к учебному случаю: оператор доказал не то, что timeout безопасен, а то, что конкретный key, scope, payload и срок хранения дали один наблюдаемый результат.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110: HTTP Semantics</a> — официальный стандарт HTTP: идемпотентные методы можно повторять после коммуникационного сбоя, а для неидемпотентного метода автоматический повтор требует дополнительного основания. RFC не задаёт универсальный прикладной контракт <code>Idempotency-Key</code>.</li><li><a href='https://fetch.spec.whatwg.org/' target='_blank' rel='noopener noreferrer'>WHATWG Fetch Standard</a> — официальный living standard Fetch: различает ответ и network error. Это объясняет, почему отсутствие ответа у клиента не доказывает отсутствие серверного эффекта; стандарт не описывает вашу бизнес-транзакцию.</li><li><a href='https://www.postgresql.org/docs/current/ddl-constraints.html' target='_blank' rel='noopener noreferrer'>PostgreSQL Documentation: Constraints</a> — официальная документация о unique constraints и составных ограничениях. Она подтверждает защиту уникальной границы в базе, но не решает гонку бизнес-эффекта с внешним API.</li></ul>"
}