8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 271,
|
||
"slug": "editorial-2020-06-field-retry-idempotency",
|
||
"title": "Потерянный ответ и повтор запроса: как не создать второй эффект",
|
||
"excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.",
|
||
"contentHtml": "<p>Пользователь отправляет заявку, ждёт две секунды и видит timeout. Он нажимает кнопку ещё раз. Через минуту в системе появляются две заявки, два письма или два списания. В логах первой попытки уже есть <code>201 Created</code>, но браузер его не получил. Цена ошибки — не только дубль. Команда тратит время на ручное удаление, пользователь не понимает, какая запись настоящая, а повторная попытка может уйти во внешнюю систему, где откат невозможен.</p>\n<p>Timeout не доказывает, что сервер ничего не сделал. Он говорит только, что конкретный клиент не дождался события в своём лимите. Запрос мог не выйти из клиента, мог застрять в proxy, мог завершить запись после закрытия соединения или мог сохранить результат и потерять ответ на обратном пути.</p>\n<p>Тезис простой: безопасный retry повторяет один пользовательский intent, а не последний HTTP-пакет. Для этого сервер связывает попытки по одному <code>Idempotency-Key</code>, проверяет тот же значимый payload и сохраняет terminal-результат. <code>requestId</code> при этом меняется на каждой сетевой попытке. Если сервис не умеет отличить повтор от новой команды, автоматический retry после неизвестного исхода нужно остановить.</p>\n<h2>Сначала разделите попытку и intent</h2>\n<p><code>requestId</code> отвечает на вопрос «какой сетевой проход мы сейчас видим?». Его создают для запроса, который прошёл через клиент, proxy и приложение. При повторе он должен быть новым. Это помогает собрать логи именно этой доставки.</p>\n<p><code>Idempotency-Key</code> отвечает на другой вопрос: «какие доставки относятся к одному действию пользователя?». Клиент сохраняет его рядом с payload с момента подтверждения формы до terminal-результата. Повтор после timeout отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, появился новый intent. Старый ключ нельзя переиспользовать для нового тела.</p>\n<p>Заголовок не делает <code>POST</code> идемпотентным сам по себе. Контракт должен описать область уникальности ключа, способ сравнения тела, срок хранения записи и ответ для повтора. В одной области ключ может быть уникален для пользователя, клиента, заказа или другого владельца операции. Нельзя выбрать область по удобству таблицы: слишком широкая область блокирует чужие операции, слишком узкая пропускает дубль.</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>. Минимальная модель содержит область ключа, сам ключ, отпечаток значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения.</p>\n<p>Первый запрос атомарно резервирует ключ в состоянии <code>in_progress</code>. Только владелец резерва может выполнить бизнес-эффект. Параллельный запрос с тем же ключом не запускает обработчик второй раз: он получает документированный ответ «операция выполняется» или читает результат после завершения. Запрос с другим отпечатком получает конфликт до бизнес-эффекта.</p>\n<p>После эффекта сервис сохраняет <code>completed</code> и данные, которые нужны для повторного ответа. Для операции внутри одной базы запись ключа, бизнес-объект и terminal-результат стоит зафиксировать одной транзакцией. Тогда повтор видит либо согласованный результат, либо отсутствие всей операции. Само наличие уникального индекса не заменяет обработку состояний, но защищает от гонки двух процессов.</p>\n<pre><code>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\"}</code></pre>\n<p>Это учебный пример. Адрес, ключ, идентификаторы и тело не относятся к production-сервису. Он проверяет только логическое свойство: один ключ и один payload дают один результат, а повтор возвращает тот же результат. Если первый запрос завершился эффектом, но ответ не дошёл до клиента, второй вызов не создаёт новую запись.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика повтора по одному пользовательскому intent</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>Сверить ключи, отпечатки payload и terminal-записи</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>Ключ переиспользовали для нового intent или клиент изменил payload</td><td>Сравнить canonical payload или его fingerprint</td><td>Вернуть отдельный conflict без запуска обработчика</td></tr><tr><td>Повтор видит <code>in_progress</code></td><td>Первая попытка ещё работает или оборвалась до terminal-записи</td><td>Проверить владельца резерва, heartbeat и дедлайн</td><td>Вернуть состояние по контракту; не удалять запись ради нового запуска</td></tr><tr><td>Внутри базы дубля нет, снаружи он есть</td><td>Внешний вызов был до сохранения terminal-состояния</td><td>Найти стабильный business ID и запись поставщика</td><td>Остановить retry до внешнего ключа или маршрута сверки результата</td></tr></tbody></table></div>\n<p>Для первой проверки не нужна полноценная distributed tracing система. Достаточно безопасных полей в существующих журналах: время, маршрут, <code>requestId</code>, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий лог не кладут. Они расширяют риск утечки и редко помогают отличить replay от второго эффекта.</p>\n<h2>Бюджет времени не отменяет результат</h2>\n<p>У операции должен быть общий дедлайн intent. В него входят ожидание первой попытки, пауза, допустимый retry и время, за которое интерфейс покажет неопределённый исход. Каждый новый retry не должен начинать этот бюджет заново. Иначе перегруженная зависимость получает бесконечный поток одинаковых команд.</p>\n<p>Нужно отдельно проверить отношения таймеров. Клиент может закрыть ожидание раньше proxy. Proxy может продолжить запрос после закрытия вкладки. Сервер может записать результат после client timeout. Это допустимые варианты доставки. Ошибка появляется там, где следующий запрос не несёт связь с первым intent и превращается в новую команду.</p>\n<p>Статус сам по себе не выбирает действие. <code>503</code> может сопровождаться <code>Retry-After</code>, но он не расширяет общий deadline. <code>4xx</code> обычно останавливает автоматический повтор, однако плохой контракт сервера может сохранить эффект до формирования ответа. Поэтому ключ, состояние операции и журнал важнее простого списка кодов.</p>\n<h2>Отрицательный путь: когда повтор запрещён</h2>\n<p>Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. В этом случае нельзя честно сказать «повтор безопасен». Покажите неопределённый исход, сохраните данные формы и передайте операцию в путь проверки статуса. Для платежа, заказа, письма или другой необратимой команды это безопаснее второго <code>POST</code> вслепую.</p>\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 и записать его область, значимый payload и общий deadline. Не начинать с агрегированной метрики timeout.</li><li>Найти для каждой попытки отдельный <code>requestId</code> и общий <code>Idempotency-Key</code>. Отсутствующее поле отметить как пробел доказательств.</li><li>Проверить canonical payload и fingerprint. Изменение значимого поля должно дать conflict до бизнес-обработчика.</li><li>Проверить атомарный резерв одного ключа двумя конкурентными запросами. Только один запрос получает право выполнить эффект.</li><li>Смоделировать потерю ответа после сохранения результата. Повтор должен вернуть тот же статус и result ID, а число эффектов должно остаться равным одному.</li><li>Проверить состояния <code>in_progress</code>, <code>completed</code> и conflict. Для каждого состояния заранее записать машинный ответ и действие клиента.</li><li>Если эффект внешний, остановить автоматический retry и найти внешний ключ или маршрут чтения результата. Не объявлять локальную запись доказательством внешнего успеха.</li><li>Зафиксировать срок хранения terminal-записи и отдельный сценарий восстановления зависшего <code>in_progress</code>. Проверить, что уборка не запускает повторную команду.</li></ol>\n<h2>Проверяемый критерий готовности</h2>\n<p>Контракт готов, если независимый инженер может по одному intent ответить на пять вопросов: какой ключ связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что происходит при неизвестном исходе. Тест с потерянным ответом показывает ровно один эффект и тот же result ID на повторе. Тест с другим payload получает conflict до эффекта. Два конкурентных запроса не создают две terminal-записи.</p>\n<p>Для внешнего эффекта дополнительно существует проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если такого пути нет, готовность не подтверждена, даже если локальный unit test зелёный. Это граница механизма: идемпотентность уменьшает повтор одного intent, но не даёт гарантии exactly once между независимыми системами.</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; описывает семантику методов, статусы и ограничения повторов, но не задаёт универсальный контракт Idempotency-Key для прикладного API.</li><li><a href=\"https://fetch.spec.whatwg.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard</a> — официальный стандарт Fetch; отделяет сетевую ошибку и получение ответа от прикладного подтверждения бизнес-операции.</li><li><a href=\"https://www.postgresql.org/docs/current/ddl-constraints.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Constraints</a> — официальная документация PostgreSQL об ограничениях, включая уникальность; она подтверждает защиту границы в хранилище, но не решает внешний эффект и recovery.</li></ul>"
|
||
}
|