{ "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 получает сохранённый ответ. Повтор с другим payload должен быть отклонён. Параллельный запрос не должен запускать второй обработчик.

\n

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

\n

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

\n

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

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

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

\n

Проверка ключа и запись состояния должны быть атомарными. Иначе два процесса одновременно увидят «ключ не найден», оба начнут побочный эффект, а затем запишут два результата. Уникальное ограничение помогает занять одну запись, но не делает внешнюю отправку письма или вызов платёжного провайдера частью той же транзакции.

\n

Пример клиента с общим deadline

\n

Ниже приведён учебный пример для браузера. Домен и payload вымышлены. Генератор использует доступный в браузере crypto.getRandomValues, а таймаут сделан через AbortController, чтобы не привязывать заметку к более позднему удобному методу AbortSignal.timeout. В рабочем API срок хранения ключа и правила повторов задаёт серверный контракт.

\n
function createKey() {\n  const bytes = new Uint8Array(16);\n  crypto.getRandomValues(bytes);\n  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n\nfunction wait(ms) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction timeoutSignal(ms) {\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), ms);\n  return { signal: controller.signal, cancel: () => clearTimeout(timer) };\n}\n\nfunction retryDelayMs(response) {\n  const header = response.headers.get('Retry-After');\n  if (!header) return 0;\n\n  const seconds = Number(header);\n  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);\n\n  const timestamp = Date.parse(header);\n  return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : 0;\n}\n\nconst intent = {\n  key: createKey(),\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    const timeout = timeoutSignal(left);\n    let response;\n    try {\n      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: timeout.signal,\n      });\n    } catch (error) {\n      timeout.cancel();\n      if (attempt === 2) return { status: 'unknown', key: intent.key };\n      continue;\n    }\n    timeout.cancel();\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\n    const delay = retryDelayMs(response);\n    if (delay >= intent.deadline - Date.now()) {\n      return { status: 'unknown', key: intent.key };\n    }\n    if (delay > 0) await wait(delay);\n  }\n\n  return { status: 'unknown', key: intent.key };\n}
\n

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

\n

Код намеренно возвращает unknown после исчерпания бюджета. Это не ошибка парсинга ответа и не доказательство отсутствия эффекта. Оператору или интерфейсу нужен status-маршрут, который ищет операцию по тому же ключу. Сетевой retry без такого маршрута только меняет вероятность дубля, но не устанавливает факт результата.

\n

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

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

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

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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

" }