{ "index": 16, "slug": "editorial-2027-07-field-reliability-capstone", "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", "excerpt": "Собираем контракт событий попытки: как связать operationId и requestId, отделить timeout от ответа и остановиться по deadline без слепого повтора.", "contentHtml": "

В журнале часто остаётся только «request failed». По такой записи нельзя понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Команда видит шум, но не видит последовательность, которая его создала.

\n

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

\n

Тезис: событие попытки должно связывать одну логическую операцию с конкретным сетевым вызовом, остатком общего времени и решением клиента. Тогда следующий retry можно проверить по полям, а не восстанавливать по догадкам.

\n

Событие должно описывать одну попытку

\n

Одна пользовательская операция может породить несколько HTTP-запросов. operationId создаётся на границе операции и остаётся прежним. requestId относится к одной сетевой попытке и меняется при retry. Поле attempt показывает порядок. Если генерировать все три идентификатора внутри низкоуровневого клиента, расследование потеряет связь между повторениями.

\n

Ниже — минимальный локальный контракт. Его имена не являются готовыми семантическими соглашениями OpenTelemetry: команда должна согласовать типы, срок хранения, доступ и правила очистки отдельно. Важно сохранить смысл полей: ответ сервера не смешивается с исключением клиента, а причина не смешивается с действием.

\n
Минимальный контракт события попытки
ПолеПримерЧто проверяет
operationIdop-42Все попытки относятся к одной операции
requestIdop-42/attempt-2Конкретный сетевой вызов и его след
attempt2Порядок и фактическое число вызовов
methodGETПрименимость политики повтора
status503 или nullОтвет получен или его нет
errorClasstimeout или nullКласс ошибки транспорта или клиента
elapsedMs80Сколько заняла попытка
remainingMs280Сколько общего бюджета было до вызова
decisionretryКакое действие выбрал клиент
\n
\"Цикл
Попытка оставляет проверяемое событие до следующего вызова. Новый requestId не разрывает operationId, а неизвестный результат записи ведёт к проверке состояния.
\n

Не смешиваем ответ, timeout и решение

\n

503 — это полученный HTTP-ответ. Он сообщает о недоступности сервиса в момент запроса, но не выбирает политику конкретного клиента. timeout — отсутствие ответа в отведённое время. Это не доказательство, что сервер не выполнил операцию. decision=retry — третье измерение: это уже решение вызывающей стороны, а не свойство ответа.

\n

Для HTTP/3 транспорт может сообщить о состоянии соединения или потока, но прикладной протокол отдельно определяет смысл данных и ошибок. Поэтому закрытый поток не превращается автоматически в «заказ не создан». В событии нужно сохранить границу знания: что увидел клиент и какой результат остался неизвестным.

\n
Что известно после разных исходов
НаблюдениеЧто известноЧего нельзя утверждатьСледующее действие
200 после GETОтвет получен, попытка завершиласьЧто следующая попытка тоже нужнаВернуть ответ и закрыть операцию
503 после GETСервис ответил временной недоступностьюЧто повтор безопасен для любого методаПроверить метод, бюджет и лимит попыток
timeout после GETКлиент не получил ответ вовремяЧто запрос не был принят серверомРассмотреть ограниченный повтор чтения
timeout после POSTРезультат прикладной записи неизвестенЧто повтор создаст только одну записьПроверить состояние по ключу операции
Deadline исчерпан до вызоваНовая попытка не начиналасьЧто зависимость получила этот вызовВернуть terminal reason без сетевого запроса
\n

Для записи безопасное действие после timeout — не новый POST, а запрос состояния по согласованному ключу или ручное разбирательство. Такой переход можно назвать check_state. Он не утверждает успех или неуспех: он сохраняет неизвестный результат до отдельной проверки.

\n

Учебный исполнитель с фиксированным timeline

\n

Пример ниже не открывает сеть. Массив observations заранее задаёт ответы и длительность, поэтому любой инженер может повторить последовательность событий на одной машине. Время здесь виртуальное: фиксированная задержка backoffMs нужна для демонстрации бюджета, а не является настройкой production-клиента.

\n
function decideAttempt({ method, status, errorClass, remainingAfterMs, attemptsLeft, backoffMs }) {\n  if (status >= 200 && status < 300) return 'return';\n  if (errorClass === 'timeout' && method !== 'GET') return 'check_state';\n  if (method === 'GET' && (status === 503 || errorClass === 'timeout')) {\n    if (remainingAfterMs <= backoffMs) return 'deadline_exceeded';\n    return attemptsLeft > 0 ? 'retry' : 'max_attempts';\n  }\n  return 'stop';\n}\n\nfunction runRetry({\n  operationId,\n  method,\n  observations,\n  maxAttempts = 3,\n  deadlineMs = 400,\n  backoffMs = 40,\n}) {\n  let spentMs = 0;\n  const events = [];\n  const totalAttempts = Math.min(maxAttempts, observations.length);\n\n  for (let index = 0; index < totalAttempts; index += 1) {\n    const observation = observations[index];\n    const remainingMs = Math.max(0, deadlineMs - spentMs);\n\n    if (remainingMs === 0) {\n      return { result: { status: 'deadline_exceeded' }, events };\n    }\n\n    const elapsedMs = Math.min(observation.elapsedMs, remainingMs);\n    const timedOut =\n      observation.errorClass === 'timeout' ||\n      observation.elapsedMs > remainingMs;\n    const status = timedOut ? null : observation.status ?? null;\n    const errorClass = timedOut ? 'timeout' : observation.errorClass ?? null;\n    const remainingAfterMs = remainingMs - elapsedMs;\n    const decision = decideAttempt({\n      method,\n      status,\n      errorClass,\n      remainingAfterMs,\n      attemptsLeft: totalAttempts - index - 1,\n      backoffMs,\n    });\n\n    events.push({\n      operationId,\n      requestId: `${operationId}/attempt-${index + 1}`,\n      attempt: index + 1,\n      method,\n      status,\n      errorClass,\n      elapsedMs,\n      remainingMs,\n      decision,\n    });\n\n    if (decision === 'retry') {\n      spentMs += elapsedMs + backoffMs;\n      continue;\n    }\n\n    return {\n      result: decision === 'return' ? { status } : { status: decision },\n      events,\n    };\n  }\n\n  return { result: { status: 'max_attempts' }, events };\n}\n\nconst read = runRetry({\n  operationId: 'op-42',\n  method: 'GET',\n  observations: [\n    { status: 503, elapsedMs: 80 },\n    { status: 503, elapsedMs: 80 },\n    { status: 200, elapsedMs: 50 },\n  ],\n});\n\nconst write = runRetry({\n  operationId: 'op-43',\n  method: 'POST',\n  observations: [{ errorClass: 'timeout', elapsedMs: 120 }],\n});\n\nconsole.log(read.events, read.result, write.events[0].decision);\n// retry, retry, return; 200; check_state
\n

Для чтения пример создаёт три события. Остаток бюджета перед попытками равен 400, 280 и 160 миллисекундам; решения — retry, retry, return. Итоговый статус — 200. Для записи timeout даёт check_state, поэтому функция не запускает второй сетевой вызов.

\n

Это узкая проверка порядка. В ней нет DNS, пула соединений, реального clock, конкурентных клиентов, server-side дедупликации и доставки событий. Именно поэтому результат примера нельзя называть измерением доступности или доказательством безопасности конкретного API.

\n

Deadline — часть события, а не настройка цикла

\n

Локальный timeout ограничивает одну попытку, а deadline ограничивает всю операцию. Если каждая из трёх попыток получает по 400 миллисекунд, вызывающий код может ждать больше секунды. Правильная схема передаёт вниз один общий момент окончания и вычисляет remainingMs перед каждым вызовом:

\n

remainingMs = deadlineMonotonic - monotonicNow

\n

Время бюджета измеряют монотонными часами. Wall-clock пригоден для корреляции записей между узлами, но скачок системных часов не должен внезапно разрешить ещё одну сетевую попытку. В событии полезно хранить и elapsedMs, и остаток до вызова: одно показывает стоимость действия, другое — доступный запас.

\n

Заголовок Retry-After нужно сохранять отдельным полем, например retryAfterMs. Сервер может подсказать задержку после 503, но клиент всё равно ограничивает её своим deadline, лимитом попыток и политикой метода. Подсказка о времени ожидания не превращает небезопасный POST в идемпотентную операцию.

\n

maxAttempts и deadline отвечают на разные вопросы. Первый ограничивает количество вызовов, второй — время всей операции. Поэтому в терминальном событии стоит различать max_attempts и deadline_exceeded. Иначе команда начнёт увеличивать число попыток, когда на самом деле зависимость отвечает слишком медленно.

\n

Как читать последовательность событий

\n

В нормальном расследовании сначала группируем записи по operationId, затем сортируем по attempt или времени начала. Внутри одной группы requestId должен быть уникальным для попытки. Если встречаются два события с одним requestId, проверяем повторную доставку логов; если меняется operationId, проверяем место создания идентификатора.

\n

Последовательность 503 → 503 → 200 означает три наблюдаемых ответа, но не «сервис был полностью недоступен». Она подтверждает только ответы конкретной операции. Последовательность 503 → deadline_exceeded означает, что клиент остановился после первой попытки; это не новый ответ зависимости.

\n

Отдельно считаем решения. Доля retry показывает поведение клиента, а доля timeout — наблюдаемый класс отказа. Не складывайте их в одну метрику ошибок: один timeout может привести к check_state, а один 503 — к безопасному ограниченному retry. Разные причины требуют разных действий.

\n

Для наблюдаемости полезно разделить сигналы. Trace связывает путь запроса, log хранит конкретное событие, metric показывает распределение попыток и долю завершений по deadline. operationId и requestId не стоит бездумно превращать в labels метрики: высокая кардинальность сделает график дорогим и малоинформативным.

\n

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

\n
Диагностическая матрица для retry-событий
СимптомПричинаПроверкаДействие
Везде только request failedСтатус, причина и решение слиты в строкуНайти status/errorClass/decision для одной операцииРазделить поля и ограничить их словарь
remainingMs растёт после retryСмешаны wall-clock и monotonic clockСверить бюджет, elapsedMs и точку начала операцииСчитать deadline одной монотонной шкалой
Есть decision=retry, но следующего вызова нетBackoff или лимит попыток съел остаток бюджетаПроверить terminal reason и попытки после событияЗаписывать deadline/max_attempts отдельно
Одна операция получила два operationIdИдентификатор создаётся внутри retry-обёрткиСопоставить входной запрос и все requestIdСоздавать operationId до первого вызова
503 повторяется для каждого методаПолитика смотрит только на статусСравнить method и доменный эффектЗапретить повтор без доказанной идемпотентности
В событии виден полный URLЛогируется вход без очисткиПроверить query, cookie, токены и телоНормализовать endpoint и удалить секреты
\n

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

\n
  1. Назовите операцию. Зафиксируйте доменное действие, побочный эффект и владельца решения. HTTP-метод остаётся подсказкой, а не полной политикой.
  2. Разделите идентификаторы. Создайте устойчивый operationId до первого вызова, выдавайте новый requestId каждой попытке и увеличивайте attempt.
  3. Опишите конечные значения. Заранее перечислите допустимые status, errorClass, decision и terminal reason. Не заменяйте их свободным сообщением.
  4. Задайте общий бюджет. Передавайте deadline вниз по стеку, отдельно ограничьте timeout вызова и включите backoff в тот же бюджет.
  5. Сохраняйте событие. Записывайте статус или класс ошибки, длительность, остаток времени и решение. В одном событии не должно быть одновременно фиктивного статуса и придуманной причины.
  6. Проверьте неизвестный результат. Для timeout записи используйте ключ операции и read-state endpoint либо ручной маршрут. Новый POST без такой проверки запрещён.
  7. Очистите данные. Уберите query-секреты, cookie, токены, тело ответа и персональные идентификаторы до отправки log или trace.
  8. Прогоните отрицательные пути. Проверьте успешный первый вызов, два 503 и 200, исчерпание deadline, timeout GET и timeout POST. Проверяйте события и число реальных вызовов.
  9. Считайте последствия. Отдельно наблюдайте распределение попыток, долю timeout, terminal reason и нагрузку зависимости. Не меняйте политику по одной записи.
\n

Ограничения

\n

Фикстура использует заранее записанный timeline и не моделирует сетевую очередь, балансировщик, потерю логов, рестарт процесса, конкуренцию и реальные серверные побочные эффекты. В production время нужно брать из монотонного clock, а задержку — из согласованной политики зависимости. Фиксированные 40 миллисекунд в коде не являются универсальным backoff.

\n

RFC описывает свойства HTTP-методов и транспортные границы, но не знает доменный эффект вашего endpoint. Даже идемпотентный метод может иметь неожиданные побочные действия из-за реализации. И наоборот, POST может получить отдельный idempotency-key контракт, но его срок, область уникальности, проверку тела и хранение результата должен доказать сервер.

\n

Событие повышает наблюдаемость, но не подтверждает, что зависимость применила запись. Trace и log могут быть неполными, metric не заменяет конкретную попытку. Для платежа, заказа или уведомления владельцу операции нужен отдельный способ проверить состояние после неопределённого исхода.

\n

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

\n

Изменение готово, если фиксированный набор входов даёт одинаковую последовательность событий при повторном запуске. Для чтения 503 → 503 → 200 должны сохраниться один operationId, три разных requestId, номера попыток 1–3, остаток 400 → 280 → 160 и решения retry → retry → return. Итогом должен быть полученный 200, а не синтетический успех после исчерпания бюджета.

\n

Для timeout GET проверяем ограничение числа вызовов и отдельный terminal reason. Для timeout POST проверяем один побочный вызов, решение check_state и отсутствие второго POST. В каждом событии есть либо status, либо errorClass, есть remainingMs и decision, а секреты не попадают в log или trace.

\n

Этот критерий не обещает доступность зависимости и не доказывает корректность её транзакции. Он проверяет границу, которой управляет клиент: ограниченный retry не превращается в лавину, а неизвестный результат записи не превращается в новый побочный эффект.

\n

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

" }