Симптом в разборе неприятный: пользователь видит 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 гарантированно лишает сервер возможности увидеть повтор.
Карточка доказательств вместо одного статуса
| Что найдено | Что это подтверждает | Что ещё неизвестно | Следующее действие |
|---|---|---|---|
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 между сторонами
| Участок | Лимит в фикстуре | Какой факт ограничивает | Риск неверного чтения |
|---|---|---|---|
| Интерфейс intent | 5000 ms | когда UI перестаёт ждать автоматически | считать это отменой операции |
| Первая попытка клиента | 1800 ms | когда transport сообщает timeout | считать, что upstream не видел request |
| Пауза перед retry | 250 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, а повтор получает сохранённый ответ. После выбора реального стека ту же последовательность нужно повторить как интеграционный тест с двумя конкурентными запросами, рестартом между действием и ответом и наблюдением журналов без чувствительных данных.
Маршрут разбора неопределённого исхода
- Взять один конкретный пользовательский intent и найти его idempotency key. Не начинать расследование с агрегированного графика timeout.
- Собрать по первой попытке requestId, edge-событие, решение backend и terminal result ID. Отметить, какое звено отсутствует, а не заполнять пробел предположением.
- Проверить повтор: key и значимый payload должны совпадать, а requestId должен быть новым. Если key новый, это уже не доказательство retry.
- Сопоставить общий budget, лимит первой попытки, паузу и лимит второй попытки. Отдельно назвать, что происходит после исчерпания budget.
- Запустить учебную или интеграционную фикстуру «response lost after stored result». Проверить один effect, replay одинакового result ID и conflict для другого payload.
- Если эффект уходит во внешнюю систему, остановить автоматический 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 — официальное описание уникальных ограничений; уникальность ключа должна проверяться хранилищем, а не только памятью одного процесса