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

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

\n

Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.

\n

Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принимать решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.

\n

Механизм отказа

\n

Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать. Если записать только финальное «503», расследование потеряет порядок событий.

\n

Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded.

\n

Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, fail и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.

\n
Минимальный контракт события попытки
ПолеПримерЗачем
operationIdop-42Связать попытки одной операции
attempt2Увидеть порядок и число вызовов
methodGETПроверить семантику повтора
status или errorClass503, timeoutОтделить ответ сервера от исключения
remainingMs180Понять, сколько времени оставалось
decisionretryЗафиксировать решение клиента
\n
\"Цикл
Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.
\n

Что именно можно повторять

\n

HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации. Серверный обработчик и его побочные эффекты остаются частью проверки.

\n

GET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и не игнорирует Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия и сохранённый результат.

\n

Учебный пример ниже намеренно консервативен. Он повторяет только GET со статусом 503. Массив ответов заменяет сеть, поэтому код не доказывает поведение конкретной библиотеки и не описывает production-систему.

\n
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n  const events = [];\n\n  for (let i = 0; i < Math.min(maxAttempts, responses.length); i += 1) {\n    const result = responses[i];\n    const remainingMs = Math.max(0, deadlineMs - i * 120);\n    const retryable = method === 'GET' && result.status === 503;\n    const decision = remainingMs === 0 ? 'fail' : retryable ? 'retry' : 'return';\n\n    events.push({\n      operationId,\n      attempt: i + 1,\n      method,\n      status: result.status,\n      remainingMs,\n      decision\n    });\n\n    if (decision !== 'retry') {\n      return { result: decision === 'fail' ? { status: 'deadline_exceeded' } : result, events };\n    }\n  }\n\n  return { result: { status: 'deadline_exceeded' }, events };\n}\n\nrunBoundedRetries({\n  operationId: 'op-42',\n  method: 'GET',\n  responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});
\n

В этом учебном наборе клиент создаёт три события и возвращает 200. Если заменить метод на POST, первый 503 получит решение return. Такой результат не означает, что любой POST надо немедленно завершать. Он показывает отрицательный путь: без доказанной идемпотентности повтор запрещён.

\n

В настоящем клиенте есть ещё одна проверка. Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.

\n

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

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Резкий рост запросов после 503Нет общего deadline или backoffСравнить attempt и remainingMsОграничить бюджет, добавить задержку и jitter
Один пользователь получил две записиPOST повторили после timeoutСопоставить operationId на сервереОстановить retry, ввести ключ и запрос состояния
В логах только «failed»Причина и decision слитыНайти поля status/errorClass и decisionСделать перечисление причин и действий
Клиент ждёт дольше SLATimeout задан на попытку, не на операциюПроверить остаток времени перед каждым вызовомПередавать общий deadline вниз по стеку
После 429 нагрузка не падаетКлиент игнорирует ограничение сервераПроверить Retry-After и частоту попытокСнизить темп и завершать попытку по политике лимита
Нельзя связать клиентский и серверный следИдентификатор меняется при retryСопоставить operationId и requestIdСохранить идентификатор операции, а запросу дать номер попытки
\n

Как читать отрицательный путь

\n

Рассмотрим последовательность для чтения. Первая попытка получила 503 при остатке 380 миллисекунд. Клиент записал decision=retry, подождал ограниченный интервал и повторил запрос. Вторая попытка снова получила 503. Осталось 120 миллисекунд, поэтому третья попытка допустима только после оценки её минимального времени выполнения. Если бюджет мал, клиент завершает операцию с deadline_exceeded, даже если в массиве есть следующий ответ.

\n

Теперь рассмотрим запись. Сервер мог принять запрос, но соединение оборвалось до ответа. Клиент записал errorClass=timeout, decision=check_state и сохранил operation key. Он не создаёт новую запись. Это медленнее, чем слепой retry, но цена неизвестного результата ниже цены дублирования побочного эффекта.

\n

Для логов достаточно безопасного endpoint без query-секретов, метода, статуса, класса ошибки, номера попытки, оставшегося времени и решения. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины. Поле наблюдаемости не должно становиться новым каналом утечки.

\n

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

\n
  1. Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.
  2. Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.
  3. Разделить timeout отдельного вызова и общий deadline операции.
  4. Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток и действие при исчерпании времени.
  5. Добавить событие попытки с attempt, status или errorClass, remainingMs и decision.
  6. Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.
  7. Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.
  8. Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.
  9. Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.
\n

Ограничения

\n

Локальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.

\n

Ни RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.

\n

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

\n

Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после двух временных отказов видны попытки 1 и 2 с decision=retry, а затем успешный возврат либо честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.

\n

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

\n

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

" }