8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"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 && 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>"
|
||
}
|