{ "index": 273, "slug": "editorial-2020-06-practice-retry-idempotency", "title": "Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет", "excerpt": "Timeout не доказывает, что сервер ничего не сделал. Разбираем, как связать один пользовательский intent с idempotency key, ограниченным retry и повторяемым результатом.", "contentHtml": "

Пользователь нажимает «Отправить». Браузер ждёт ответ, затем показывает timeout. Пользователь нажимает ещё раз. Первая команда могла уже создать заказ, заявку или письмо. Ответ потерялся между сервисом и браузером. Теперь система получила два запроса и не знает, считать ли их одной операцией. Цена ошибки — двойной эффект, неверный статус на экране и ручное выяснение, что именно произошло.

\n

Отключённая кнопка не закрывает проблему. Запрос повторит браузер после обновления страницы, клиентская библиотека после разрыва соединения или прокси после сетевого сбоя. Без контракта повтор создаёт новую команду. С контрактом он возвращает результат уже начатой команды.

\n

Тезис: повторяют intent, а не сетевой пакет

\n

Безопасный retry начинается до первого HTTP-вызова. Клиент выделяет один пользовательский intent: конкретную операцию, набор значимых полей, пользователя и срок действия. Для intent он создаёт idempotency key. Все попытки этой операции передают тот же ключ и тот же payload. Новый набор полей получает новый intent и новый ключ.

\n

Ключ не делает POST идемпотентным сам по себе. Сервер должен принять решение по паре «scope ключа и fingerprint запроса», атомарно занять операцию, выполнить эффект один раз и сохранить terminal-ответ. Повтор с тем же ключом получает сохранённый ответ. Повтор с тем же ключом, но другим payload получает конфликт. Параллельный запрос видит состояние in_progress, а не запускает второй обработчик.

\n

Механизм по шагам

\n

Timeout сообщает только об отсутствии ответа у клиента к дедлайну. Он не сообщает, дошёл ли запрос до сервера, завершилась ли запись и был ли отправлен ответ. Поэтому после timeout нельзя создавать новый ключ и нельзя автоматически считать операцию отменённой. Клиент либо повторяет тот же intent в пределах общего бюджета, либо показывает неопределённый исход и предлагает получить статус отдельным read-запросом.

\n

На сервере нужен scope. Для учебной заявки его можно составить из аутентифицированного субъекта, имени операции и ключа. Это не даёт одинаковой строке ключа связывать действия разных пользователей или разных операций. Fingerprint строят по значимым полям. Его сравнение защищает от ошибки, когда клиент повторно использовал старый ключ для уже изменённой формы.

\n
\"Путь
Timeout означает, что ответ не наблюдался. Он не означает, что эффект не произошёл. Повтор с тем же ключом должен вернуть прежний результат.
\n

У записи операции есть как минимум четыре состояния: ключ не найден, операция выполняется, операция завершена с сохранённым ответом и ключ отклонён из-за другого payload. Одного поля seen=true недостаточно. По нему нельзя понять, ждать ли первый запрос, повторить ли готовый ответ или показать причину отказа.

\n

Пример запроса и состояния клиента

\n

Ниже приведён учебный пример. Домен api.example.invalid, идентификатор заявки и тайминги вымышлены. Код показывает границу ответственности: ключ создаётся один раз, deadline относится ко всему intent, а не к каждой попытке.

\n
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}
\n

X-Client-Attempt помогает читать журнал, но не определяет идентичность операции. Если включить номер попытки в idempotency key, второй запрос станет новой командой. Если при каждом timeout заново запускать восьмисекундный таймер, клиент сможет повторять запросы бесконечно долго и усилит нагрузку на уже нестабильную зависимость.

\n

Сервис должен хранить ключ вместе со scope, fingerprint, состоянием, HTTP-кодом и телом terminal-ответа. При первой попытке он резервирует запись до выполнения побочного эффекта. После успеха или окончательной ошибки он заполняет результат. При повторе с тем же fingerprint сервис отдаёт эту запись. Учебный код не утверждает, что конкретная база или транспорт уже дают такую атомарность: её нужно обеспечить отдельно.

\n

Симптом → причина → проверка → действие

\n
Диагностика повторов для одной операции
СимптомПричинаПроверкаДействие
После timeout появились две записиПовтор получил новый ключ или сервер не дедуплицирует запросыСопоставить ключ, scope и счётчик побочных эффектовСоздавать ключ до первого вызова и атомарно резервировать intent
Повтор получает конфликт из-за payloadСтарый ключ использовали для изменённой формыСравнить fingerprint значимых полейЗакрыть старый intent и создать новый ключ после изменения данных
Два параллельных запроса выполняются одновременноПроверка ключа и запись in_progress не атомарныЗапустить два запроса с одним key и проверить порядок в хранилищеДобавить уникальное ограничение и отдельную ветку для уже занятой операции
Клиент повторяет запросы до бесконечностиУ каждой попытки собственный timeout, нет общего deadlineПосчитать время от создания intent до последнего вызоваЗадать общий бюджет и конечное число попыток
Повтор вернул пустой или другой результатСервис сохранил только факт ключа, но не terminal-ответСравнить код, тело и идентификатор результата первой и второй попыткиСохранять и воспроизводить ответ по контракту операции
Пользователь видит ошибку, хотя эффект созданUI трактует отсутствие ответа как rollbackПроверить серверный журнал и status-маршрут по ключуПоказывать неопределённый исход и дать безопасный путь проверки
\n

Порядок внедрения

\n
  1. Выберите одну операцию с побочным эффектом. Зафиксируйте, что именно считается одним intent и какие поля определяют результат.
  2. Создайте ключ до первого вызова. Проверьте двойной клик, обновление страницы и повтор после сетевого timeout.
  3. Определите scope ключа: субъект, операция и срок хранения. Не используйте номер попытки или номер пользователя как единственный ключ.
  4. Посчитайте fingerprint значимых полей. При несовпадении payload остановите запрос до побочного эффекта.
  5. Атомарно резервируйте in_progress. Параллельный запрос должен ждать, получить документированный конфликт или прочитать статус.
  6. Сохраните terminal-код, тело и идентификатор результата. Повтор с тем же key должен получить тот же наблюдаемый результат.
  7. Задайте общий deadline и короткую policy retry для временных ошибок. После бюджета не отправляйте новую команду вслепую.
  8. Проверьте сценарий «операция завершилась, ответ потерялся». Счётчик эффекта должен остаться равен одному, а повтор — вернуть тот же result ID.
\n

Отрицательный путь и ограничения

\n

Не каждый отказ можно повторять. Ошибка валидации требует исправить payload. Ошибка авторизации требует обновить права или сессию. Повтор 4xx без изменения причины только создаёт шум. Коды 502, 503 и 504 могут указывать на временную проблему, но сами по себе не доказывают, что сервер не выполнил операцию.

\n

Идемпотентность одной границы не распространяется на внешнюю систему. Если обработчик сначала создаёт запись у себя, а затем отправляет письмо в сервис без ключа, повторная доставка письма всё ещё может дать дубль. Нужны outbox, устойчивый бизнес-идентификатор или поддержка дедупликации у поставщика. Если внешний эффект необратим и его результат неизвестен, безопаснее остановиться и получить статус, чем слепо отправить новую команду.

\n

Ключ не заменяет авторизацию, шифрование и контроль доступа. Он не должен раскрывать результат другому субъекту. Запись ключей нужно очищать по документированному сроку, а чувствительные поля нельзя бездумно помещать в журналы. Учебные значения из примера нельзя переносить в рабочие лимиты, схемы хранения или правила повторов без измерений конкретной системы.

\n

Проверяемый критерий готовности

\n

Операция готова к ограниченному retry, если команда может воспроизвести четыре сценария: успешный первый ответ, timeout после выполнения, параллельный повтор и изменение payload под старым ключом. В первом сценарии клиент получает результат. Во втором система создаёт один эффект и возвращает тот же result ID. В третьем выполняется один обработчик. В четвёртом сервер отклоняет запрос до побочного эффекта.

\n

Отдельно проверьте бюджет: после его исчерпания нет новых попыток, UI показывает неопределённый исход или использует предусмотренный status-маршрут. Такой критерий наблюдаем по журналу запросов, состояниям записи и счётчику эффекта. Он не зависит от предположения, что потерянный ответ означает отменённую операцию.

\n

Проверяемые источники

" }