{ "index": 16, "slug": "editorial-2027-07-field-reliability-capstone", "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", "excerpt": "Собираем контракт событий попытки: как связать operationId и requestId, отделить timeout от ответа и остановиться по deadline без слепого повтора.", "contentHtml": "
В журнале часто остаётся только «request failed». По такой записи нельзя понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Команда видит шум, но не видит последовательность, которая его создала.
Цена ошибки — лишняя нагрузка для чтения и повторный побочный эффект для записи: второй заказ, два письма или повторное списание. Timeout добавляет неопределённость: сервер мог не получить запрос, мог его отклонить или уже применить, пока ответ потерялся. Поэтому строка «повторить при ошибке» недостаточна.
\nТезис: событие попытки должно связывать одну логическую операцию с конкретным сетевым вызовом, остатком общего времени и решением клиента. Тогда следующий retry можно проверить по полям, а не восстанавливать по догадкам.
\nОдна пользовательская операция может породить несколько HTTP-запросов. operationId создаётся на границе операции и остаётся прежним. requestId относится к одной сетевой попытке и меняется при retry. Поле attempt показывает порядок. Если генерировать все три идентификатора внутри низкоуровневого клиента, расследование потеряет связь между повторениями.
Ниже — минимальный локальный контракт. Его имена не являются готовыми семантическими соглашениями OpenTelemetry: команда должна согласовать типы, срок хранения, доступ и правила очистки отдельно. Важно сохранить смысл полей: ответ сервера не смешивается с исключением клиента, а причина не смешивается с действием.
\n| Поле | Пример | Что проверяет |
|---|---|---|
operationId | op-42 | Все попытки относятся к одной операции |
requestId | op-42/attempt-2 | Конкретный сетевой вызов и его след |
attempt | 2 | Порядок и фактическое число вызовов |
method | GET | Применимость политики повтора |
status | 503 или null | Ответ получен или его нет |
errorClass | timeout или null | Класс ошибки транспорта или клиента |
elapsedMs | 80 | Сколько заняла попытка |
remainingMs | 280 | Сколько общего бюджета было до вызова |
decision | retry | Какое действие выбрал клиент |
503 — это полученный HTTP-ответ. Он сообщает о недоступности сервиса в момент запроса, но не выбирает политику конкретного клиента. timeout — отсутствие ответа в отведённое время. Это не доказательство, что сервер не выполнил операцию. decision=retry — третье измерение: это уже решение вызывающей стороны, а не свойство ответа.
Для HTTP/3 транспорт может сообщить о состоянии соединения или потока, но прикладной протокол отдельно определяет смысл данных и ошибок. Поэтому закрытый поток не превращается автоматически в «заказ не создан». В событии нужно сохранить границу знания: что увидел клиент и какой результат остался неизвестным.
\n| Наблюдение | Что известно | Чего нельзя утверждать | Следующее действие |
|---|---|---|---|
200 после GET | Ответ получен, попытка завершилась | Что следующая попытка тоже нужна | Вернуть ответ и закрыть операцию |
503 после GET | Сервис ответил временной недоступностью | Что повтор безопасен для любого метода | Проверить метод, бюджет и лимит попыток |
timeout после GET | Клиент не получил ответ вовремя | Что запрос не был принят сервером | Рассмотреть ограниченный повтор чтения |
timeout после POST | Результат прикладной записи неизвестен | Что повтор создаст только одну запись | Проверить состояние по ключу операции |
| Deadline исчерпан до вызова | Новая попытка не начиналась | Что зависимость получила этот вызов | Вернуть terminal reason без сетевого запроса |
Для записи безопасное действие после timeout — не новый POST, а запрос состояния по согласованному ключу или ручное разбирательство. Такой переход можно назвать check_state. Он не утверждает успех или неуспех: он сохраняет неизвестный результат до отдельной проверки.
Пример ниже не открывает сеть. Массив observations заранее задаёт ответы и длительность, поэтому любой инженер может повторить последовательность событий на одной машине. Время здесь виртуальное: фиксированная задержка backoffMs нужна для демонстрации бюджета, а не является настройкой production-клиента.
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, поэтому функция не запускает второй сетевой вызов.
Это узкая проверка порядка. В ней нет DNS, пула соединений, реального clock, конкурентных клиентов, server-side дедупликации и доставки событий. Именно поэтому результат примера нельзя называть измерением доступности или доказательством безопасности конкретного API.
\nЛокальный timeout ограничивает одну попытку, а deadline ограничивает всю операцию. Если каждая из трёх попыток получает по 400 миллисекунд, вызывающий код может ждать больше секунды. Правильная схема передаёт вниз один общий момент окончания и вычисляет remainingMs перед каждым вызовом:
remainingMs = deadlineMonotonic - monotonicNow
Время бюджета измеряют монотонными часами. Wall-clock пригоден для корреляции записей между узлами, но скачок системных часов не должен внезапно разрешить ещё одну сетевую попытку. В событии полезно хранить и elapsedMs, и остаток до вызова: одно показывает стоимость действия, другое — доступный запас.
Заголовок Retry-After нужно сохранять отдельным полем, например retryAfterMs. Сервер может подсказать задержку после 503, но клиент всё равно ограничивает её своим deadline, лимитом попыток и политикой метода. Подсказка о времени ожидания не превращает небезопасный POST в идемпотентную операцию.
maxAttempts и deadline отвечают на разные вопросы. Первый ограничивает количество вызовов, второй — время всей операции. Поэтому в терминальном событии стоит различать max_attempts и deadline_exceeded. Иначе команда начнёт увеличивать число попыток, когда на самом деле зависимость отвечает слишком медленно.
В нормальном расследовании сначала группируем записи по operationId, затем сортируем по attempt или времени начала. Внутри одной группы requestId должен быть уникальным для попытки. Если встречаются два события с одним requestId, проверяем повторную доставку логов; если меняется operationId, проверяем место создания идентификатора.
Последовательность 503 → 503 → 200 означает три наблюдаемых ответа, но не «сервис был полностью недоступен». Она подтверждает только ответы конкретной операции. Последовательность 503 → deadline_exceeded означает, что клиент остановился после первой попытки; это не новый ответ зависимости.
Отдельно считаем решения. Доля retry показывает поведение клиента, а доля timeout — наблюдаемый класс отказа. Не складывайте их в одну метрику ошибок: один timeout может привести к check_state, а один 503 — к безопасному ограниченному retry. Разные причины требуют разных действий.
Для наблюдаемости полезно разделить сигналы. Trace связывает путь запроса, log хранит конкретное событие, metric показывает распределение попыток и долю завершений по deadline. operationId и requestId не стоит бездумно превращать в labels метрики: высокая кардинальность сделает график дорогим и малоинформативным.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Везде только 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 и удалить секреты |
operationId до первого вызова, выдавайте новый requestId каждой попытке и увеличивайте attempt.status, errorClass, decision и terminal reason. Не заменяйте их свободным сообщением.503 и 200, исчерпание deadline, timeout GET и timeout POST. Проверяйте события и число реальных вызовов.Фикстура использует заранее записанный timeline и не моделирует сетевую очередь, балансировщик, потерю логов, рестарт процесса, конкуренцию и реальные серверные побочные эффекты. В production время нужно брать из монотонного clock, а задержку — из согласованной политики зависимости. Фиксированные 40 миллисекунд в коде не являются универсальным backoff.
\nRFC описывает свойства HTTP-методов и транспортные границы, но не знает доменный эффект вашего endpoint. Даже идемпотентный метод может иметь неожиданные побочные действия из-за реализации. И наоборот, POST может получить отдельный idempotency-key контракт, но его срок, область уникальности, проверку тела и хранение результата должен доказать сервер.
Событие повышает наблюдаемость, но не подтверждает, что зависимость применила запись. Trace и log могут быть неполными, metric не заменяет конкретную попытку. Для платежа, заказа или уведомления владельцу операции нужен отдельный способ проверить состояние после неопределённого исхода.
\nИзменение готово, если фиксированный набор входов даёт одинаковую последовательность событий при повторном запуске. Для чтения 503 → 503 → 200 должны сохраниться один operationId, три разных requestId, номера попыток 1–3, остаток 400 → 280 → 160 и решения retry → retry → return. Итогом должен быть полученный 200, а не синтетический успех после исчерпания бюджета.
Для timeout GET проверяем ограничение числа вызовов и отдельный terminal reason. Для timeout POST проверяем один побочный вызов, решение check_state и отсутствие второго POST. В каждом событии есть либо status, либо errorClass, есть remainingMs и decision, а секреты не попадают в log или trace.
Этот критерий не обещает доступность зависимости и не доказывает корректность её транзакции. Он проверяет границу, которой управляет клиент: ограниченный retry не превращается в лавину, а неизвестный результат записи не превращается в новый побочный эффект.
\nRetry-After и статус 503 Service Unavailable. RFC не выбирает retry-политику конкретного API и не доказывает отсутствие побочного эффекта.