DarkRiDDeR14 мин

Разбор: ответ потерян, заявка создана — как проверить retry без второго эффекта

HTTPРазборНадёжность

Симптом в разборе неприятный: пользователь видит timeout, повторяет отправку и получает два одинаковых результата. В журнале первой попытки иногда уже есть 201, но фронтенд его не увидел; иногда есть только запись proxy; иногда в базе нет ничего. Цена ошибки зависит от выбранной догадки. Если команда называет любой timeout «неуспешным запросом», она добавит повтор без ключа и создаст дубль. Если называет любой timeout «сервер точно сделал работу», она остановит пользователя даже там, где request не дошёл до обработчика.

Ниже не production-инцидент, а детерминированная учебная фикстура июня 2020 года. В ней одна заявка на технический разбор, условный edge и память процесса вместо настоящей базы. Нет персональных данных, платёжных операций, реального hostname, browser trace или измерения нагрузки. Фикстура всё же полезна: она сохраняет порядок «первый результат записан → ответ потерян → retry приходит тем же key → сервер делает replay» и не даёт статье выдать красивую схему за доказательство чужой инфраструктуры.

Разделяем три исхода timeout

Фраза «запрос упал по timeout» скрывает минимум три разных пути. Первый: клиент не дождался соединения, а upstream вообще не увидел request. Второй: proxy или сервис принял request, но обработчик ещё работает. Третий: сервис записал результат и сформировал ответ, но ответ потерялся на пути к клиенту. Только третий путь даёт классическую картину «операция выполнена, UI сообщает ошибку». У всех трёх может быть одинаковая кнопка и похожая ошибка в console, поэтому расследование начинается не с увеличения timeout, а с одного ключа и временной последовательности.

На учебном маршруте я использую два независимых идентификатора. requestId принадлежит одной сетевой попытке и меняется на retry. Idempotency-Key принадлежит одному intent и остаётся тем же. Первый помогает собрать строку edge и строку приложения для конкретного HTTP-прохода. Второй отвечает на другой вопрос: две попытки хотят один effect или два? Подмена одного идентификатора другим ломает разбор: одинаковый requestId в разных hops может быть ошибкой логирования, а новый key на retry гарантированно лишает сервер возможности увидеть повтор.

Вертикальная временная схема расследования: первая попытка получает requestId req-51 и один idempotency key, сервис сохраняет result ID, ответ не наблюдается клиентом до его дедлайна, вторая попытка req-52 с тем же key получает replay без второго эффекта
Для расследования нужны оба измерения: requestId показывает путь одной доставки, а idempotency key связывает две доставки с одним пользовательским действием.

Карточка доказательств вместо одного статуса

Сигнал, гипотеза и действие в учебном разборе
Что найденоЧто это подтверждаетЧто ещё неизвестноСледующее действие
Edge видит req-51, app не видит keyпервая попытка дошла не до обработчикабыл ли upstream доступен позжепроверить маршрут proxy и разрешить retry тем же key в общем budget
App пишет in_progress, terminal записи нетобработчик начал работузавершится ли он после таймера клиентане запускать второй handler; вернуть/проверить status по договору
App пишет stored status=201результат сохранёнвидел ли первый клиент ответretry должен получить replay того же result ID
Тот же key с другой hashклиент пытается изменить intentпочему state формы не сброшенвернуть conflict; создать новый intent только после явного изменения
Две terminal записи с разными result IDидемпотентная граница отсутствует или не атомарнакакой из effects уже виден внешней системеостановить blind retry и исследовать порядок записи/внешнего вызова

Эта таблица не требует полноценной distributed tracing системы. Для первой проверки достаточно согласовать безопасные поля в уже существующих журналах: время, route, requestId, сокращённый или внутренне нормализованный key, решение idempotency и result ID. Полный payload, cookie, authorization и сообщение ошибки внешнего поставщика в такой журнал не кладут. Они не помогают отличить replay от second effect, зато расширяют поверхность данных именно во время сбоя.

Учебный HTTP-запрос и две разные попытки

Ниже два вызова намеренно используют локальный адрес и один демонстрационный key. Команды не выполнялись в этом пакете: здесь нет сервера на 127.0.0.1:48080. Их задача — сделать проверяемым условие: второй запрос не получает новый ключ и тот же payload не превращается в новую заявку. Если проект использует другой route, JSON serializer или аутентификацию, меняется конкретная команда, но не связь между intent, key и result.

# Учебная команда для изолированного стенда, не production endpoint.
curl --max-time 2 --request POST http://127.0.0.1:48080/v1/demo-requests \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: demo-key-retry-2020-06-a7f1" \
  --data "{"topic":"bundle review","note":"fixture"}"

# После искусственно потерянного ответа повторяем те же bytes и тот же key.
curl --max-time 2 --request POST http://127.0.0.1:48080/v1/demo-requests \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: demo-key-retry-2020-06-a7f1" \
  --data "{"topic":"bundle review","note":"fixture"}"

Утилита curl --max-time в таком упражнении ограничивает ожидание клиента, но не отменяет request на сервере. Именно это и надо смоделировать: после первого timeout нельзя делать вывод, что записи нет. При наличии idempotency contract второй вызов может честно получить тот же 201 и demo-request-42 — это не «старая ошибка в кеше», а наблюдаемый результат одного intent. Если service contract возвращает иной статус для replay, это также нужно зафиксировать заранее и проверить тестом.

Журнал отвечает на вопрос о порядке

Учебный след ниже показывает третий исход timeout. req-51 дошёл до приложения; приложение сохранило result; клиент не дождался ответа. После этого req-52 имеет другой requestId, но тот же key и получает решение replay. Строка stored стоит перед client timeout не потому, что edge «всегда быстрее», а потому, что именно такой порядок зафиксирован в данной фикстуре. Реальный разбор должен получить порядок из часов и логов своего контура, а не подогнать их под этот текст.

2020-06-18T10:00:00.090Z edge requestId=req-51 route=demo-request key=demo-key-retry-2020-06-a7f1 upstream=sent
2020-06-18T10:00:00.214Z app  requestId=req-51 key=demo-key-retry-2020-06-a7f1 decision=owner state=in_progress
2020-06-18T10:00:00.341Z app  requestId=req-51 key=demo-key-retry-2020-06-a7f1 decision=stored status=201 result=demo-request-42
2020-06-18T10:00:01.801Z client requestId=req-51 outcome=timeout response=not_observed
2020-06-18T10:00:02.080Z edge requestId=req-52 route=demo-request key=demo-key-retry-2020-06-a7f1 upstream=sent
2020-06-18T10:00:02.093Z app  requestId=req-52 key=demo-key-retry-2020-06-a7f1 decision=replay status=201 result=demo-request-42

Если в настоящем логе нет одного из полей, не стоит немедленно добавлять десяток новых метрик. Сначала выбрать развилку, которую нельзя решить: дошёл ли request до приложения, успела ли запись стать terminal, или пришёл ли повтор с тем же key. Затем добавить одно безопасное поле и пройти контролируемый запрос на стенде. Так журнал становится техническим контрактом между frontend, proxy и backend, а не списком строк, который читают только после инцидента.

Сверяем временной budget между сторонами

Учебный бюджет одной заявки, а не рекомендованные production числа
УчастокЛимит в фикстуреКакой факт ограничиваетРиск неверного чтения
Интерфейс intent5000 msкогда UI перестаёт ждать автоматическисчитать это отменой операции
Первая попытка клиента1800 msкогда transport сообщает timeoutсчитать, что upstream не видел request
Пауза перед retry250 msкогда ещё есть смысл в том же intentсоздать новый key ради скорости
Вторая попытка1800 msпоследняя автоматическая проверка результатаповторять бесконечно на перегруженной зависимости
Остаток1150 msвремя показать неопределённый исход/статусскрыть состояние пользователя за ещё одним request

Числа намеренно не совпадают с настройками proxy или базы: их здесь нет. В реальном контуре нужно сначала нарисовать, где устанавливаются client, edge и upstream timeout, а затем сделать общий budget достаточным для осмысленного UX, но конечным. Если proxy ждёт дольше клиента, service может продолжить после закрытия вкладки — это не ошибка само по себе. Ошибкой станет отсутствие ключа и terminal записи, из-за чего следующий визит пользователя создаст новый effect вместо чтения старого результата.

Детерминированная фикстура проверяет именно логическое свойство

В модуле этой партии есть команда node scripts/upgrade-2020-06.mjs --verify-fixture. Она не запускает HTTP, curl, PostgreSQL, proxy или browser. Она держит три записи в памяти: первая обработка сохраняет один result и теряет ответ для клиента; повтор с тем же key читает result; тот же key с другой hash получает conflict. Такая граница полезна для статьи: можно проверить, что refactor не переставил «создать effect» после replay-проверки и не сделал mismatch вторым effect.

// Фактический вывод учебной команды имеет четыре истинных условия:
{
  "effects": 1,
  "checks": {
    "firstWasUnknownToClient": true,
    "onlyOneEffect": true,
    "retryReplayedStoredResult": true,
    "payloadMismatchRejected": true
  }
}

Фикстура не доказывает, что выбранная база выдерживает гонку, что proxy действительно теряет ответ именно так или что внешний сервис примет дубликат ключа. Она проверяет более узкое свойство учебного алгоритма: один key с одним payload создаёт один result, а повтор получает сохранённый ответ. После выбора реального стека ту же последовательность нужно повторить как интеграционный тест с двумя конкурентными запросами, рестартом между действием и ответом и наблюдением журналов без чувствительных данных.

Маршрут разбора неопределённого исхода

  1. Взять один конкретный пользовательский intent и найти его idempotency key. Не начинать расследование с агрегированного графика timeout.
  2. Собрать по первой попытке requestId, edge-событие, решение backend и terminal result ID. Отметить, какое звено отсутствует, а не заполнять пробел предположением.
  3. Проверить повтор: key и значимый payload должны совпадать, а requestId должен быть новым. Если key новый, это уже не доказательство retry.
  4. Сопоставить общий budget, лимит первой попытки, паузу и лимит второй попытки. Отдельно назвать, что происходит после исчерпания budget.
  5. Запустить учебную или интеграционную фикстуру «response lost after stored result». Проверить один effect, replay одинакового result ID и conflict для другого payload.
  6. Если эффект уходит во внешнюю систему, остановить автоматический retry до появления внешнего idempotency contract или безопасного статусного маршрута.

Что этот разбор не утверждает

Он не утверждает, что любой 201 должен кэшироваться навсегда, что 409 имеет одно и то же значение у каждого API или что две системы получат exactly once по одному заголовку. Он показывает более приземлённую технику: разделить unknown outcome на несколько путей, хранить решение по одному intent и проверять порядок следом событий. Это соответствует уровню М3: frontend, delivery и backend уже рассматриваются вместе, но без выдуманной observability stack и без истории о масштабном инциденте.

В пакете реально выполнится только in-memory fixture из revision-модуля; curl-команды, журналы, тайминги и адрес являются учебными данными. Не запускались browser, proxy, production endpoint, нагрузка, база или внешняя система. Перед практическим использованием нужны согласованный API-contract, privacy review журнала, проверка конкуренции в выбранном хранилище, измеренные таймауты и отдельный сценарий восстановления для внешнего эффекта.

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

  • 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 сообщает о бездействии соединения, но сам по себе не отменяет бизнес-эффект на сервере
  • PostgreSQL 12: Constraints — официальное описание уникальных ограничений; уникальность ключа должна проверяться хранилищем, а не только памятью одного процесса