DarkRiDDeR14 мин

Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет

HTTPFrontendBackend

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

Для июньского материала 2020 года возьму учебную операцию создания внутренней заявки на разбор bundle. У неё нет платёжных реквизитов, реального домена и production endpoint. Зато есть граница, общая для frontend, delivery и backend: клиент повторяет не HTTP-байты, а один пользовательский intent; сервис хранит итог этого intent; timeout получает конечный бюджет. Цель не в том, чтобы повторять каждый сбой, а в том, чтобы после неопределённого исхода не создавать второй эффект.

Сначала называем один intent, а не одну попытку

Intent появляется, когда пользователь подтвердил конкретный набор полей. В этот момент клиент получает ключ идемпотентности и держит его рядом с payload до terminal результата: принятой заявки, понятной ошибки валидации или сознательной отмены. Первая отправка и безопасный повтор несут одинаковый ключ. Если пользователь изменил тему или содержание заявки, это уже другой intent: старый ключ закрывают, новому payload дают новый ключ. Повторно использовать ключ для другого тела нельзя, иначе сервер не различит retry и две разные команды.

Ключ не должен быть номером пользователя, порядковым номером формы или значением, которое легко угадать. В статье функция генерации оставлена за контрактом проекта: учебное значение нужно только для чтения следа. В IETF-драфте начала 2020 года ключ описан как значение, по которому resource узнаёт повтор, но это был work in progress, а не причина считать любой заголовок универсально поддержанным. Если команда выбирает имя Idempotency-Key, она документирует scope, срок хранения, правило для изменённого payload и ответ на параллельный повтор.

Вертикальная схема пути одной учебной заявки: пользовательский intent получает один ключ, первая попытка доходит до сервиса и сохраняет результат, ответ теряется, а вторая попытка с тем же ключом получает сохранённый результат без нового эффекта
Timeout сообщает клиенту, что ответ не наблюдался. Он не даёт права считать серверную операцию отменённой; повтор остаётся безопасным только при общем ключе и сохранённом результате.

Контракт клиента: что остаётся неизменным

Состояние одной учебной заявки на стороне клиента
ЭлементНа первой попыткеНа retryКогда меняется
Payloadсериализован из подтверждённой формытот же набор значимых полейтолько после нового действия пользователя
Idempotency keyсоздан для intentпередан без заменыпри новом intent, а не при timeout
Номер попытки1 для журналаувеличиваетсяне участвует в идентичности эффекта
Дедлайн intentотсчитывается один разсокращает доступное времяне продлевается бесконечно каждым retry
Отображаемый статусожидание ответанеопределённый исход или повторпосле терминального ответа сервиса

Таблица отделяет два похожих поля. Номер попытки полезен для журнала и UI, но не может быть частью ключа: тогда вторая попытка станет для backend новой командой. Дедлайн тоже принадлежит intent, а не одному сетевому вызову. Если при каждом timeout запускать новый пятисекундный таймер, страница может держать запросы и повторять их дольше, чем пользователь готов ждать. В результате возникает всплеск нагрузки именно в момент, когда зависимость уже отвечает плохо.

Минимальный HTTP-след без настоящего endpoint

В примере ниже адрес с зоной .invalid и значения намеренно вымышлены. Две отправки одного payload должны дать один и тот же сохранённый результат. Сервис вправе вернуть код и тело первого завершённого ответа ещё раз; он не обязан показывать клиенту внутреннюю запись deduplication. Если первый запрос ещё обрабатывается, контракт должен сообщить отдельный конфликт или статус ожидания, а не запускать обработчик параллельно.

POST /v1/demo-requests HTTP/1.1
Host: api.training.invalid
Content-Type: application/json
Idempotency-Key: demo-key-retry-2020-06-a7f1
X-Client-Attempt: 2

{"topic":"bundle review","note":"учебная заявка без персональных и платёжных данных"}

HTTP/1.1 201 Created
Content-Type: application/json

{"requestId":"demo-request-42","state":"accepted"}

Метод POST сам по себе не становится идемпотентным из-за имени header. RFC 7231 различает семантику методов: повторить idempotent method клиент может при сбое связи, но для POST проект обязан отдельно описать эффект. Поэтому в ревью API недостаточно увидеть заголовок. Нужно задать четыре вопроса: на каком scope ключ уникален, какая часть payload сравнивается, сколько живёт запись и какой именно ответ получает дубль. Без этих ответов retry остаётся надеждой на порядок сетевых пакетов.

Ключ создаётся до первого вызова и переживает timeout

Код не привязан к конкретному fetch-клиенту. Он показывает только порядок владения состоянием: intent создан один раз, deadline тоже задан один раз, каждая попытка передаёт те же key и payload. Transport обязан сообщить, был ли получен HTTP-ответ или возник транспортный сбой. Он не должен сам незаметно генерировать новый key в обработчике timeout: этот шаг разрушает связь с результатом на backend.

// Учебный код: transport передаётся извне, реальный endpoint не вызывается.
function beginIntent(payload, keyFactory, now) {
  return {
    key: keyFactory(),
    payload: payload,
    createdAt: now(),
    deadlineAt: now() + 5000,
    attempt: 0,
    state: "pending",
  };
}

async function sendIntent(intent, transport, now) {
  if (now() >= intent.deadlineAt) {
    return { kind: "budget_exhausted", key: intent.key };
  }

  intent.attempt += 1;
  return transport.post("/v1/demo-requests", intent.payload, {
    "Idempotency-Key": intent.key,
    "X-Client-Attempt": String(intent.attempt),
  });
}

// При timeout пользовательский intent не получает новый key.
// Новый key появляется только после нового действия или изменения payload.

В настоящем UI состояние можно держать в модели формы, а при необходимости пережить reload — в явно выбранном хранилище с коротким сроком жизни. Это проектное решение: локальное хранилище удобно, но на общем устройстве может раскрыть содержимое черновика; память вкладки теряется после перезагрузки. Во всех вариантах ключ не является секретом и не заменяет авторизацию. Сервис всё равно связывает запись с аутентифицированным субъектом и операцией, а не делает глобальную таблицу, где чужой key раскрывает чужой результат.

Бюджет времени ограничивает число безопасных попыток

Timeout — не доказательство отсутствия эффекта. Он означает лишь, что конкретная сторона не дождалась следующего события в своём лимите. Прокси мог отправить request upstream, приложение могло завершить запись, а клиент уже закрыл ожидание. Поэтому retry после timeout всегда использует тот же ключ; отмена ожидания не равна rollback на сервере. В Node 12 ClientRequest.setTimeout даёт обработчику сигнал таймера, но решение оборвать request и дальнейшая судьба сервиса остаются отдельной логикой.

Интерфейсный бюджет: 5000 ms
├─ попытка 1: ждать ответ не более 1800 ms
├─ короткая пауза и проверка состояния: 250 ms
├─ попытка 2 тем же ключом: ждать не более 1800 ms
└─ 1150 ms: показать неопределённый исход и дать безопасный следующий шаг

Числа в этой схеме учебные. Их нельзя переносить в production без данных о proxy, приложении и пользовательском сценарии. Полезен сам порядок: общий budget больше одной попытки; первая попытка имеет предел; между попытками есть короткая развилка; финал не обязан снова стучаться в сеть. Если второй ответ не получен, UI сохраняет ключ и показывает, что исход неизвестен, либо предлагает отдельный статусный запрос, если такой read-маршрут предусмотрен контрактом.

Решение о повторе: статус не подменяет причину
Наблюдение клиентаЧто неизвестноПроверка контрактаДействие
Ответ 201 или сохранённый terminal результатничего о повтореключ и payload совпализакрыть intent и показать результат
Сетевой timeout после отправкисервер мог выполнить операциюесть ключ и ещё есть общий budgetодин ограниченный retry тем же key
409 для того же keyпервая попытка могла идтиответ документирован как in-progress/conflictне создавать второй key; подождать или запросить статус по договору
Ошибка валидации 4xxpayload не станет правильным самв ответе названы полявернуть форму в редактирование и создать новый intent после изменения
503 с понятной политикой сервисавозможен временный отказ до или после эффектаесть ограничение retries и одинаковый keyприменить policy операции, не бесконечный цикл

Не стоит превращать список кодов в автомат. 503 может сопровождаться Retry-After, но этот header не отменяет общий deadline пользователя. 4xx не всегда означает отсутствие записи, если сервер плохо описал порядок работы, поэтому основной критерий всё равно один: retry намерен повторить только тот же intent и передаёт тот же key. Если сервис не поддерживает такой контракт, честнее остановиться, показать неопределённый исход и провести проверку владельцем операции, чем послать вторую команду вслепую.

Маршрут внедрения в одном сервисе

  1. Выбрать одну операцию с видимым побочным эффектом: создание учебной заявки, резервирование слота или запуск отчёта. Не начинать со всех POST сразу.
  2. Описать, что является одним intent: значимые поля payload, аутентифицированный субъект, операция и срок, в который повтор считается тем же действием.
  3. Сделать ключ частью модели intent до первого HTTP-вызова. Проверить reload, двойной клик и timeout: ни один путь не создаёт новый key для того же payload.
  4. На backend сохранить key вместе с scope, fingerprint и terminal результатом. Параллельный повтор обязан встретить запись, а не второй обработчик.
  5. Задать общий временной budget и короткую policy повторов. Проверить отдельно транспортный timeout, ответ валидации, conflict in-progress и повтор готового результата.
  6. Пройти изолированную фикстуру «ответ потерян, операция завершена»: первый клиент не видит ответ, второй получает тот же result ID, а счётчик эффектов остаётся равен одному.

Границы практического рецепта

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

Здесь не запускались browser, proxy, production API, реальная база и никакая платёжная интеграция. Адрес, payload, result ID, тайминги и заголовок X-Client-Attempt принадлежат учебному примеру. Перед применением команда должна отдельно сверить версию клиента, таймауты посредников, схему аутентификации, политику очистки ключей и конкретный способ получить статус операции после исчерпания budget.

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

  • RFC 7231: HTTP/1.1 Semantics and Content — стандарт HTTP, актуальный в 2020 году: семантика idempotent methods, статусы 409/503 и поле Retry-After; позже заменён RFC 9110
  • IETF draft-idempotency-header-01, январь 2020 — исторический work in progress, а не утверждённый RFC: описывает ключ, fingerprint, повтор завершённой операции и конфликт параллельного повтора
  • Node.js v12: http.ClientRequest.setTimeout — официальная документация линии Node 12: таймер request сообщает о бездействии соединения, но сам по себе не отменяет бизнес-эффект на сервере