{ "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Представим два логических запроса. Для каждого сервис делает первичный вызов и разрешает одну дополнительную попытку. Если каждый слой владеет retry, число физических вызовов растёт быстрее, чем ожидает владелец верхнего слоя. Edge может повторить вызов адаптера, а адаптер — повторить тот же вызов внешнего API. В результате одна ошибка получает два независимых счётчика.
\nРепликация добавляет ширину. Правило «попробовать все доступные реплики» не ограничивает работу. При задержке или частичном отказе оно запускает несколько запросов до того, как первый результат станет понятен. Fallback добавляет ещё одну ветку. Если fallback сам умеет повторять вызов, его нельзя считать запасным результатом: это второй retry-контур.
\nОграниченный маршрут устроен иначе. Edge владеет двумя попытками. Каждая попытка выбирает одну реплику. Fallback получает управление только после именованного исхода, например `optional-result-unavailable`, и не создаёт retry. После второй попытки система возвращает заранее определённый деградированный результат или явно отказывает. Такая схема не обещает восстановить полный ответ. Она ограничивает стоимость отказа и оставляет понятную трассу.
\nСначала назовите отказ. `temporary-timeout` отличается от ошибки контракта или отказа авторизации. Повтор допустим только для исходов, для которых владелец операции подтвердил безопасность и полезность повторения. HTTP 503 сообщает о временной неспособности обработать запрос, но сам по себе не доказывает, что конкретную операцию можно безопасно повторить. То же относится к заголовку `Retry-After`: он передаёт подсказку о времени, но не назначает владельца retry.
\nЗатем назовите владельца. В системе может быть несколько компонентов, которые технически способны повторять запрос. Это не значит, что каждый должен это делать. Зафиксируйте один слой, его `maxAttempts`, список повторяемых исходов и момент, когда он прекращает работу. Если два слоя имеют `enabled: true`, проверьте их совместно: верхний повтор может повторять уже повторённую работу.
\nПосле этого назовите предел маршрута. `maxFanout: 1` означает, что одна попытка выбирает одну реплику. Список из трёх реплик не даёт права обращаться ко всем трём одновременно. Если предел не задан, его нельзя вывести из количества имён в списке. Неявный предел не является защитой.
\nПоследним назовите terminal action. Это может быть деградированный ответ, ошибка с понятным кодом или сохранение результата частичной операции. Он зависит от предметной области. Для операции с финансовым побочным эффектом нельзя бездумно возвращать «неполный успех». Важен сам принцип: после terminal action нет скрытого retry и нового fallback.
\nНиже приведён самодостаточный JavaScript-пример. Он работает только с переданным объектом и не вызывает сеть. Числа показывают форму контракта, а не рекомендуемые значения для production. Перед переносом в сервис их нужно заменить правилами конкретной операции и подтвердить безопасность повтора.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Число внешних вызовов выше числа пользовательских запросов | Повторяют несколько слоёв | Сопоставить 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, число запросов и исход отказа | Разделить сценарии и не усреднять результаты |
Ограничение считается рабочим только тогда, когда оно останавливает неправильные конфигурации. Включите retry у edge и adapter одновременно. Проверка должна вернуть статус о нескольких владельцах, а не выбрать один молча. Удалите имя fallback. Результатом должна стать остановка с причиной, а не переход к безымянному ответу. Замените `maxFanout: 1` на `null`. Проверка обязана остановить сценарий до выбора реплик.
\nУдалите limit point. Не подставляйте его из `maxAttempts`: это разные свойства. `maxAttempts` ограничивает конкретный счётчик попыток. Limit point отвечает за порядок: бюджет должен быть проверен до того, как начнётся новое расширение маршрута. Если сценарий использует семь логических запросов вместо двух или другой failure injection, его нельзя сравнивать с базовым примером. Сначала выровняйте входы, затем сравнивайте trace.
\nОтдельно проверьте постоянную ошибку контракта. Её нельзя автоматически считать временным timeout. Повтор не исправит несовместимую схему и может увеличить нагрузку. Для неё нужен другой путь: быстрый отказ, карантин сообщения или согласованная миграция. Универсальный retry по любому статусу — частая причина каскада.
\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Описанный механизм не выбирает оптимальный timeout. Он не знает пропускную способность сервиса, размер очереди, стоимость подключения, deadline клиента и долю ошибок зависимости. Маленький `maxAttempts` может быть правильным для одного чтения и опасным для другой операции. Значение нужно выводить из контракта и capacity модели, а не копировать из примера.
\nМеханизм не подтверждает идемпотентность. Повтор чтения обычно отличается от повтора платежа, создания заказа или отправки письма. Если запрос мог изменить состояние, сначала определите ключ идемпотентности и границу подтверждения. При отсутствии такого контракта безопаснее остановиться, чем включить retry ради доступности.
\nМеханизм не заменяет отмену in-flight работы. Если верхний слой уже вернул terminal action, нижний вызов может продолжать занимать соединение. Нужны deadline, cancellation и проверка поведения клиента. Также отдельной проверки требуют circuit breaker, rate limit, очередь и политика деградации. Единый бюджет не устраняет эти компоненты, но не даёт им бесконтрольно складывать новые попытки.
\nПримеры в статье фиксируют значения только для объяснения механизма. Они не содержат production-трафик, реальные latency, измерения восстановления или обещания SLA. Нельзя писать в отчёте «сервис выдерживает отказ» только потому, что объект прошёл проверку. Доказательство требует воспроизводимого сценария в целевой системе и согласованного критерия результата.
\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