8 lines
17 KiB
JSON
8 lines
17 KiB
JSON
{
|
||
"index": 273,
|
||
"slug": "editorial-2020-06-practice-retry-idempotency",
|
||
"title": "Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет",
|
||
"excerpt": "Timeout не доказывает, что сервер ничего не сделал. Разбираем, как связать один пользовательский intent с idempotency key, ограниченным retry и повторяемым результатом.",
|
||
"contentHtml": "<p>Пользователь нажимает «Отправить». Браузер ждёт ответ, затем показывает timeout. Пользователь нажимает ещё раз. Первая команда могла уже создать заказ, заявку или письмо. Ответ потерялся между сервисом и браузером. Теперь система получила два запроса и не знает, считать ли их одной операцией. Цена ошибки — двойной эффект, неверный статус на экране и ручное выяснение, что именно произошло.</p>\n<p>Отключённая кнопка не закрывает проблему. Запрос повторит браузер после обновления страницы, клиентская библиотека после разрыва соединения или прокси после сетевого сбоя. Без контракта повтор создаёт новую команду. С контрактом он возвращает результат уже начатой команды.</p>\n<h2>Тезис: повторяют intent, а не сетевой пакет</h2>\n<p>Безопасный retry начинается до первого HTTP-вызова. Клиент выделяет один пользовательский intent: конкретную операцию, набор значимых полей, пользователя и срок действия. Для intent он создаёт idempotency key. Все попытки этой операции передают тот же ключ и тот же payload. Новый набор полей получает новый intent и новый ключ.</p>\n<p>Ключ не делает <code>POST</code> идемпотентным сам по себе. Сервер должен принять решение по паре «scope ключа и fingerprint запроса», атомарно занять операцию, выполнить эффект один раз и сохранить terminal-ответ. Повтор с тем же ключом получает сохранённый ответ. Повтор с тем же ключом, но другим payload получает конфликт. Параллельный запрос видит состояние <code>in_progress</code>, а не запускает второй обработчик.</p>\n<h2>Механизм по шагам</h2>\n<p>Timeout сообщает только об отсутствии ответа у клиента к дедлайну. Он не сообщает, дошёл ли запрос до сервера, завершилась ли запись и был ли отправлен ответ. Поэтому после timeout нельзя создавать новый ключ и нельзя автоматически считать операцию отменённой. Клиент либо повторяет тот же intent в пределах общего бюджета, либо показывает неопределённый исход и предлагает получить статус отдельным read-запросом.</p>\n<p>На сервере нужен scope. Для учебной заявки его можно составить из аутентифицированного субъекта, имени операции и ключа. Это не даёт одинаковой строке ключа связывать действия разных пользователей или разных операций. Fingerprint строят по значимым полям. Его сравнение защищает от ошибки, когда клиент повторно использовал старый ключ для уже изменённой формы.</p>\n<figure><img src=\"/assets/editorial/2020/retry-idempotency-request-path-2020.svg\" alt=\"Путь одного intent: ключ, первая попытка, потерянный ответ и повтор с сохранённым результатом\"><figcaption>Timeout означает, что ответ не наблюдался. Он не означает, что эффект не произошёл. Повтор с тем же ключом должен вернуть прежний результат.</figcaption></figure>\n<p>У записи операции есть как минимум четыре состояния: ключ не найден, операция выполняется, операция завершена с сохранённым ответом и ключ отклонён из-за другого payload. Одного поля <code>seen=true</code> недостаточно. По нему нельзя понять, ждать ли первый запрос, повторить ли готовый ответ или показать причину отказа.</p>\n<h2>Пример запроса и состояния клиента</h2>\n<p>Ниже приведён учебный пример. Домен <code>api.example.invalid</code>, идентификатор заявки и тайминги вымышлены. Код показывает границу ответственности: ключ создаётся один раз, deadline относится ко всему intent, а не к каждой попытке.</p>\n<pre><code>const intent = {\n key: crypto.randomUUID(),\n payload: { reportType: 'bundle-size', branch: 'main' },\n deadline: Date.now() + 8_000,\n};\n\nasync function sendWithRetry() {\n for (const attempt of [1, 2]) {\n const left = intent.deadline - Date.now();\n if (left <= 0) return { status: 'unknown', key: intent.key };\n\n try {\n const response = await fetch('https://api.example.invalid/reports', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Idempotency-Key': intent.key,\n 'X-Client-Attempt': String(attempt),\n },\n body: JSON.stringify(intent.payload),\n signal: AbortSignal.timeout(left),\n });\n\n if (response.ok) return response.json();\n if (![408, 429, 502, 503, 504].includes(response.status)) {\n return { status: 'rejected', code: response.status };\n }\n } catch (error) {\n if (attempt === 2) return { status: 'unknown', key: intent.key };\n }\n }\n\n return { status: 'unknown', key: intent.key };\n}</code></pre>\n<p><code>X-Client-Attempt</code> помогает читать журнал, но не определяет идентичность операции. Если включить номер попытки в idempotency key, второй запрос станет новой командой. Если при каждом timeout заново запускать восьмисекундный таймер, клиент сможет повторять запросы бесконечно долго и усилит нагрузку на уже нестабильную зависимость.</p>\n<p>Сервис должен хранить ключ вместе со scope, fingerprint, состоянием, HTTP-кодом и телом terminal-ответа. При первой попытке он резервирует запись до выполнения побочного эффекта. После успеха или окончательной ошибки он заполняет результат. При повторе с тем же fingerprint сервис отдаёт эту запись. Учебный код не утверждает, что конкретная база или транспорт уже дают такую атомарность: её нужно обеспечить отдельно.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика повторов для одной операции</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>После timeout появились две записи</td><td>Повтор получил новый ключ или сервер не дедуплицирует запросы</td><td>Сопоставить ключ, scope и счётчик побочных эффектов</td><td>Создавать ключ до первого вызова и атомарно резервировать intent</td></tr><tr><td>Повтор получает конфликт из-за payload</td><td>Старый ключ использовали для изменённой формы</td><td>Сравнить fingerprint значимых полей</td><td>Закрыть старый intent и создать новый ключ после изменения данных</td></tr><tr><td>Два параллельных запроса выполняются одновременно</td><td>Проверка ключа и запись <code>in_progress</code> не атомарны</td><td>Запустить два запроса с одним key и проверить порядок в хранилище</td><td>Добавить уникальное ограничение и отдельную ветку для уже занятой операции</td></tr><tr><td>Клиент повторяет запросы до бесконечности</td><td>У каждой попытки собственный timeout, нет общего deadline</td><td>Посчитать время от создания intent до последнего вызова</td><td>Задать общий бюджет и конечное число попыток</td></tr><tr><td>Повтор вернул пустой или другой результат</td><td>Сервис сохранил только факт ключа, но не terminal-ответ</td><td>Сравнить код, тело и идентификатор результата первой и второй попытки</td><td>Сохранять и воспроизводить ответ по контракту операции</td></tr><tr><td>Пользователь видит ошибку, хотя эффект создан</td><td>UI трактует отсутствие ответа как rollback</td><td>Проверить серверный журнал и status-маршрут по ключу</td><td>Показывать неопределённый исход и дать безопасный путь проверки</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li>Выберите одну операцию с побочным эффектом. Зафиксируйте, что именно считается одним intent и какие поля определяют результат.</li><li>Создайте ключ до первого вызова. Проверьте двойной клик, обновление страницы и повтор после сетевого timeout.</li><li>Определите scope ключа: субъект, операция и срок хранения. Не используйте номер попытки или номер пользователя как единственный ключ.</li><li>Посчитайте fingerprint значимых полей. При несовпадении payload остановите запрос до побочного эффекта.</li><li>Атомарно резервируйте <code>in_progress</code>. Параллельный запрос должен ждать, получить документированный конфликт или прочитать статус.</li><li>Сохраните terminal-код, тело и идентификатор результата. Повтор с тем же key должен получить тот же наблюдаемый результат.</li><li>Задайте общий deadline и короткую policy retry для временных ошибок. После бюджета не отправляйте новую команду вслепую.</li><li>Проверьте сценарий «операция завершилась, ответ потерялся». Счётчик эффекта должен остаться равен одному, а повтор — вернуть тот же result ID.</li></ol>\n<h2>Отрицательный путь и ограничения</h2>\n<p>Не каждый отказ можно повторять. Ошибка валидации требует исправить payload. Ошибка авторизации требует обновить права или сессию. Повтор <code>4xx</code> без изменения причины только создаёт шум. Коды <code>502</code>, <code>503</code> и <code>504</code> могут указывать на временную проблему, но сами по себе не доказывают, что сервер не выполнил операцию.</p>\n<p>Идемпотентность одной границы не распространяется на внешнюю систему. Если обработчик сначала создаёт запись у себя, а затем отправляет письмо в сервис без ключа, повторная доставка письма всё ещё может дать дубль. Нужны outbox, устойчивый бизнес-идентификатор или поддержка дедупликации у поставщика. Если внешний эффект необратим и его результат неизвестен, безопаснее остановиться и получить статус, чем слепо отправить новую команду.</p>\n<p>Ключ не заменяет авторизацию, шифрование и контроль доступа. Он не должен раскрывать результат другому субъекту. Запись ключей нужно очищать по документированному сроку, а чувствительные поля нельзя бездумно помещать в журналы. Учебные значения из примера нельзя переносить в рабочие лимиты, схемы хранения или правила повторов без измерений конкретной системы.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Операция готова к ограниченному retry, если команда может воспроизвести четыре сценария: успешный первый ответ, timeout после выполнения, параллельный повтор и изменение payload под старым ключом. В первом сценарии клиент получает результат. Во втором система создаёт один эффект и возвращает тот же result ID. В третьем выполняется один обработчик. В четвёртом сервер отклоняет запрос до побочного эффекта.</p>\n<p>Отдельно проверьте бюджет: после его исчерпания нет новых попыток, UI показывает неопределённый исход или использует предусмотренный status-маршрут. Такой критерий наблюдаем по журналу запросов, состояниям записи и счётчику эффекта. Он не зависит от предположения, что потерянный ответ означает отменённую операцию.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2\" target=\"_blank\" rel=\"noopener\">RFC 9110, раздел 9.2.2: идемпотентные методы и автоматический retry</a></li><li><a href=\"https://docs.stripe.com/api/idempotent_requests\" target=\"_blank\" rel=\"noopener\">Stripe API: Idempotent requests</a></li></ul>"
|
||
}
|