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

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

\n

Цена каскада состоит не только из лишних запросов. Команда теряет связь между исходным запросом и его повторами. Логи показывают несколько похожих ошибок. Метрики смешивают первичную работу и повторную. Пользователь получает задержку вместо ответа, а перегруженный сервис продолжает принимать новую работу. Если система не знает, где остановиться, каждая защитная мера увеличивает масштаб отказа.

\n

Главный тезис прост: retry, fallback и репликация должны подчиняться одному явному бюджету. Его нужно применять до расширения маршрута. У повторной попытки должен быть один владелец, у fan-out — целочисленный предел, у fallback — имя и конечный результат. Когда бюджет исчерпан, система должна выполнить terminal action. Она не должна незаметно создавать ещё один уровень попыток.

\n
\"Каскад
Единая граница отделяет ограниченное восстановление от нового витка нагрузки. Красная ветка заканчивается окончательным действием, а не очередной попыткой.
\n

Как возникает усиление отказа

\n

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

\n

Репликация добавляет ширину. Правило «попробовать все доступные реплики» не ограничивает работу. При задержке или частичном отказе оно запускает несколько запросов до того, как первый результат станет понятен. Fallback добавляет ещё одну ветку. Если fallback сам умеет повторять вызов, его нельзя считать запасным результатом: это второй retry-контур.

\n

Ограниченный маршрут устроен иначе. Edge владеет двумя попытками. Каждая попытка выбирает одну реплику. Fallback получает управление только после именованного исхода, например `optional-result-unavailable`, и не создаёт retry. После второй попытки система возвращает заранее определённый деградированный результат или явно отказывает. Такая схема не обещает восстановить полный ответ. Она ограничивает стоимость отказа и оставляет понятную трассу.

\n

Что должно быть названо в контракте

\n

Сначала назовите отказ. `temporary-timeout` отличается от ошибки контракта или отказа авторизации. Повтор допустим только для исходов, для которых владелец операции подтвердил безопасность и полезность повторения. HTTP 503 сообщает о временной неспособности обработать запрос, но сам по себе не доказывает, что конкретную операцию можно безопасно повторить. То же относится к заголовку `Retry-After`: он передаёт подсказку о времени, но не назначает владельца retry.

\n

Затем назовите владельца. В системе может быть несколько компонентов, которые технически способны повторять запрос. Это не значит, что каждый должен это делать. Зафиксируйте один слой, его `maxAttempts`, список повторяемых исходов и момент, когда он прекращает работу. Если два слоя имеют `enabled: true`, проверьте их совместно: верхний повтор может повторять уже повторённую работу.

\n

После этого назовите предел маршрута. `maxFanout: 1` означает, что одна попытка выбирает одну реплику. Список из трёх реплик не даёт права обращаться ко всем трём одновременно. Если предел не задан, его нельзя вывести из количества имён в списке. Неявный предел не является защитой.

\n

Последним назовите terminal action. Это может быть деградированный ответ, ошибка с понятным кодом или сохранение результата частичной операции. Он зависит от предметной области. Для операции с финансовым побочным эффектом нельзя бездумно возвращать «неполный успех». Важен сам принцип: после terminal action нет скрытого retry и нового fallback.

\n

Пример ограниченного маршрута

\n

Ниже приведён самодостаточный JavaScript-пример. Он работает только с переданным объектом и не вызывает сеть. Числа показывают форму контракта, а не рекомендуемые значения для production. Перед переносом в сервис их нужно заменить правилами конкретной операции и подтвердить безопасность повтора.

\n
const route = {\n  retry: {\n    owner: 'edge',\n    maxAttempts: 2,\n    retryable: ['temporary-timeout'],\n  },\n  replicas: {\n    names: ['primary-a', 'primary-b', 'primary-c'],\n    maxFanout: 1,\n  },\n  fallback: {\n    name: 'named-summary',\n    trigger: 'optional-result-unavailable',\n    addsRetry: false,\n    output: 'degraded-summary',\n  },\n  limit: {\n    point: 'before-route-expansion',\n    onExhaustion: 'return-degraded-result',\n  },\n};\n\nfunction nextStep(outcome, attempt) {\n  if (route.retry.retryable.includes(outcome) &&\n      attempt < route.retry.maxAttempts) {\n    return { action: 'retry', owner: route.retry.owner };\n  }\n\n  if (outcome === route.fallback.trigger) {\n    return { action: 'fallback', name: route.fallback.name };\n  }\n\n  return { action: route.limit.onExhaustion };\n}\n\nconsole.log(nextStep('temporary-timeout', 2));\n// { action: 'return-degraded-result' }
\n

Функция не измеряет задержку и не проверяет реальную доступность реплики. Она показывает важную границу: после второй попытки результат не переходит к новому retry. Для реальной реализации нужно дополнительно передать идентификатор логического запроса, сохранить причину остановки и связать события одной трассой. Не следует считать этот вывод доказательством устойчивости сервиса.

\n

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

\n
Диагностика каскада без смешения гипотез
СимптомПричинаПроверкаДействие
Число внешних вызовов выше числа пользовательских запросовПовторяют несколько слоёвСопоставить owner и attempt в traceОставить одного владельца retry
При одном timeout растёт нагрузка на все репликиНе задан fan-out на попыткуПосчитать выбранные реплики для одного logical requestЗадать целочисленный maxFanout и проверить его до расширения маршрута
Fallback запускается после каждой ошибкиНе различены retryable и terminal outcomesПроверить trigger и список повторяемых исходовНазвать trigger и запретить fallback создавать retry
В trace появляются действия без владельцаЛогика скрыта в библиотеке или промежуточном адаптереНайти первый span, который создаёт новый вызовДобавить owner и событие расхода бюджета
После исчерпания попыток запрос продолжает житьНет terminal action или отмены in-flight работыПроверить последнюю запись trace и состояние очередиВернуть явный результат и прекратить дальнейшее расширение
Два сценария дают разные цифры, но считаются сопоставимымиРазличаются logical load или failure injectionСверить baseline key, число запросов и исход отказаРазделить сценарии и не усреднять результаты
\n

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

\n
  1. Зафиксируйте один логический запрос и его ожидаемый результат. Отделите его от физических вызовов к зависимостям.
  2. Назовите failure injection: цель, исход и область действия. Не заменяйте конкретный timeout общим словом «сбой».
  3. Найдите всех владельцев retry. Оставьте один слой, если операция не требует другой схемы с доказанным бюджетом.
  4. Задайте `maxAttempts` и список `retryable` outcomes. Для каждого исхода проверьте идемпотентность и смысл повтора.
  5. Опишите реплики и `maxFanout`. Убедитесь, что одна попытка выбирает не больше разрешённого числа направлений.
  6. Опишите fallback: имя, trigger, результат и отсутствие собственного retry. Если ветка делает несколько вызовов, вынесите её в отдельный ограниченный контракт.
  7. Поставьте limit point до расширения маршрута. Запишите, что происходит при исчерпании бюджета.
  8. Соберите trace из событий одного logical request. Каждый новый вызов должен иметь причину, владельца и номер попытки.
  9. Повторите отрицательные сценарии: второй retry owner, пустой fallback, `maxFanout: null`, отсутствующий limit и несовпадающий baseline.
  10. Сверьте результат с тем же сигналом, который обнаружил проблему. Не заменяйте проверку заявлением о будущей эффективности.
\n

Отрицательный путь важнее зелёного примера

\n

Ограничение считается рабочим только тогда, когда оно останавливает неправильные конфигурации. Включите retry у edge и adapter одновременно. Проверка должна вернуть статус о нескольких владельцах, а не выбрать один молча. Удалите имя fallback. Результатом должна стать остановка с причиной, а не переход к безымянному ответу. Замените `maxFanout: 1` на `null`. Проверка обязана остановить сценарий до выбора реплик.

\n

Удалите limit point. Не подставляйте его из `maxAttempts`: это разные свойства. `maxAttempts` ограничивает конкретный счётчик попыток. Limit point отвечает за порядок: бюджет должен быть проверен до того, как начнётся новое расширение маршрута. Если сценарий использует семь логических запросов вместо двух или другой failure injection, его нельзя сравнивать с базовым примером. Сначала выровняйте входы, затем сравнивайте trace.

\n

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

\n

Что покажет трасса

\n

Полезная трасса отвечает на четыре вопроса: какой логический запрос начал работу, какой исход получил каждый вызов, кто решил повторить и где система остановилась. Для ограниченного примера последовательность может выглядеть так: `logical-01 → primary-a → temporary-timeout`; `edge → retry budget 2 → 1`; `logical-01 → primary-b → complete`. Для второго запроса: `primary-c → optional-result-unavailable`; затем `named-summary → degraded-summary`.

\n

Такой список не является метрикой производительности. Он нужен, чтобы восстановить решение. Если в нём есть вызов, которого нет в retry plan, fallback или replication policy, модель неполна. Если последний span заканчивается ошибкой, но очередь продолжает принимать работу того же класса, причина может находиться выше: лимит стоит слишком поздно, а не просто имеет неправильное число.

\n

Связывайте повтор с логическим идентификатором, но не записывайте в trace секреты и пользовательские данные. Внешний trace id помогает найти событие, но не является доказательством личности или разрешением на доступ. Наблюдаемость должна объяснять расход бюджета и границу остановки.

\n

Ограничения применимости

\n

Описанный механизм не выбирает оптимальный timeout. Он не знает пропускную способность сервиса, размер очереди, стоимость подключения, deadline клиента и долю ошибок зависимости. Маленький `maxAttempts` может быть правильным для одного чтения и опасным для другой операции. Значение нужно выводить из контракта и capacity модели, а не копировать из примера.

\n

Механизм не подтверждает идемпотентность. Повтор чтения обычно отличается от повтора платежа, создания заказа или отправки письма. Если запрос мог изменить состояние, сначала определите ключ идемпотентности и границу подтверждения. При отсутствии такого контракта безопаснее остановиться, чем включить retry ради доступности.

\n

Механизм не заменяет отмену in-flight работы. Если верхний слой уже вернул terminal action, нижний вызов может продолжать занимать соединение. Нужны deadline, cancellation и проверка поведения клиента. Также отдельной проверки требуют circuit breaker, rate limit, очередь и политика деградации. Единый бюджет не устраняет эти компоненты, но не даёт им бесконтрольно складывать новые попытки.

\n

Примеры в статье фиксируют значения только для объяснения механизма. Они не содержат production-трафик, реальные latency, измерения восстановления или обещания SLA. Нельзя писать в отчёте «сервис выдерживает отказ» только потому, что объект прошёл проверку. Доказательство требует воспроизводимого сценария в целевой системе и согласованного критерия результата.

\n

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

\n

Для одного класса операции есть заполненные failure injection, retry owner, `maxAttempts`, retryable outcomes, `maxFanout`, fallback и terminal action. Trace связывает каждый физический вызов с одним logical request. Неправильные конфигурации останавливаются с отдельными причинами: несколько retry owners, безымянный fallback, неограниченный fan-out, отсутствие limit point и несопоставимый сценарий.

\n

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

\n

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

" }