{ "index": 67, "slug": "editorial-2026-02-field-resilience", "title": "Retry под нагрузкой: как остановить каскад запросов", "excerpt": "Медленная зависимость сама по себе не валит сервис: его валят неограниченные повторы, fan-out и поздний fallback. Разбираем бюджет попыток, проверку трассы и безопасную деградацию.", "contentHtml": "

Внешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.

\n

Ниже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.

\n
\"Один
Одна логическая операция должна иметь видимую границу: повтор, выбор направления, fallback и окончательное действие не могут бесконечно порождать новые вызовы.
\n

Сначала отделим симптом от механизма

\n

Симптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.

\n

Полезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, read-01 получает temporary-timeout от primary-a, один раз повторяется на primary-b и получает ok. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется primary-c, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.

\n

Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает Retry-After как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.

\n

Считаем физическую работу

\n

Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.

\n

Для проектирования разделите бюджет на четыре поля:

\n\n

Бюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.

\n

Retry, fallback и hedging — разные операции

\n

Retry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.

\n

Официальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации maxAttempts задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.

\n

У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом retryEnabled: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.

\n

Контракт: исход, deadline и идемпотентность

\n

Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, PUT и DELETE, но допускает повтор POST только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.

\n

Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.

\n

Укажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте logical_id, attempt, owner, outcome, выбранное направление и причину остановки.

\n

Воспроизводимый пример без сети

\n

Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.

\n
node <<'NODE'\nconst policy = {\n  maxAttempts: 2,\n  maxFanout: 1,\n  retryable: new Set(['temporary-timeout']),\n  fallbackTrigger: 'optional-result-unavailable',\n  onExhaustion: 'fail-fast',\n};\n\nfunction decide(outcome, attempt) {\n  if (outcome === 'ok') return { action: 'complete' };\n  if (outcome === policy.fallbackTrigger) {\n    return { action: 'fallback', name: 'cached-summary' };\n  }\n  if (policy.retryable.has(outcome) &&\n      attempt < policy.maxAttempts) {\n    return { action: 'retry', owner: 'edge' };\n  }\n  return { action: policy.onExhaustion };\n}\n\nconst requests = [\n  { logicalId: 'read-01', outcomes: ['temporary-timeout', 'ok'] },\n  { logicalId: 'read-02', outcomes: ['optional-result-unavailable'] },\n  { logicalId: 'read-03', outcomes: ['temporary-timeout', 'temporary-timeout'] },\n];\n\nfor (const request of requests) {\n  for (let i = 0; i < request.outcomes.length; i += 1) {\n    const attempt = i + 1;\n    const outcome = request.outcomes[i];\n    const decision = decide(outcome, attempt);\n    console.log(JSON.stringify({\n      logicalId: request.logicalId,\n      attempt,\n      outcome,\n      ...decision,\n    }));\n    if (decision.action !== 'retry') break;\n  }\n}\nNODE
\n

Ожидаемый вывод — две попытки для read-01, одна ветка fallback для read-02 и две попытки с fail-fast для read-03. В этом примере maxFanout присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.

\n

Как читать трассу

\n

Trace — путь запроса через приложение. Для этой задачи он должен отвечать на пять вопросов: какой логический запрос начался, сколько было попыток, кто разрешил каждую, какой исход получен и где создан terminal action. OpenTelemetry разделяет traces, metrics и logs; не стоит подменять отсутствующую связь между попытками общей метрикой latency.

\n

Минимальная полезная последовательность выглядит так: read-01 / attempt=1 / edge / primary-a / temporary-timeout; затем read-01 / attempt=2 / edge / primary-b / ok. Для read-03 последняя запись должна содержать attempt=2 и fail-fast. Если после неё виден вызов fallback или третьей реплики, ограничение стоит слишком поздно либо другой слой повторяет работу.

\n

Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.

\n

Матрица симптомов и действий

\n
Диагностика каскада по одному logical request
НаблюдениеПроверяемая гипотезаEvidenceДействие
Физических вызовов больше, чем пользовательских операцийRetry включён на нескольких слояхСверить owner и attempt в traceОставить одного владельца или ввести общую квоту
Один timeout обращается ко всем репликамFan-out не ограничен на попыткуПосчитать направления для одного logical_idЗадать целочисленный maxFanout и проверить его до выбора
Fallback вызывает ту же зависимость повторноЗапасная ветка содержит скрытый retryНайти дочерние spans после fallbackЗапретить повтор или включить вызовы в общий бюджет
Последний span закрыт, но очередь продолжает растиНет отмены in-flight работы или load sheddingСопоставить deadline, cancellation и queue depthОстановить позднюю работу и отклонять нагрузку раньше
Нельзя объяснить, почему повтор разрешёнOutcome классифицируется по общему 5xxСверить исход, метод и семантику операцииРазделить retryable и terminal outcomes
После исправления цифра лучше, но сценарий изменилсяСравниваются разные входыПроверить failure injection и baseline keyПовторить оба запуска на одинаковой нагрузке
\n

Порядок безопасной проверки

\n
  1. Опишите один логический запрос и допустимый результат. Не смешивайте его с количеством сетевых вызовов.
  2. Назовите failure injection: зависимость, исход, длительность и область действия.
  3. Найдите всех владельцев retry в клиенте, SDK, адаптере, gateway и очереди.
  4. Для каждого retryable исхода проверьте идемпотентность или ключ, который защищает повтор.
  5. Задайте общий deadline, maxAttempts, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.
  6. Ограничьте fan-out на одну попытку. Для hedging отдельно проверьте отмену проигравших вызовов.
  7. Опишите fallback и terminal action. После terminal action не должно быть скрытого retry.
  8. Добавьте в trace logical_id, attempt, owner, outcome, направление и причину остановки.
  9. Выполните положительный и отрицательный сценарии: восстановление, fallback, исчерпание бюджета, второй retry owner и неограниченный fan-out.
  10. Сравните результат с тем же сигналом, который обнаружил проблему: количество физических вызовов, очередь, latency и долю ошибок.
\n

Что нельзя обещать по этому примеру

\n

Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.

\n

Равным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. Retry-After следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.

\n

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

\n

Наконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.

\n

Критерий готовности

\n

Для каждого класса операций есть заполненные owner, retryable outcomes, maxAttempts, deadline, backoff, maxFanout, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.

\n

Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.

\n

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

" }