{ "index": 67, "slug": "editorial-2026-02-field-resilience", "title": "Retry под нагрузкой: как остановить каскад запросов", "excerpt": "Медленная зависимость сама по себе не валит сервис: его валят неограниченные повторы, fan-out и поздний fallback. Разбираем бюджет попыток, проверку трассы и безопасную деградацию.", "contentHtml": "
Внешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.
\nНиже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.
\nСимптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.
\nПолезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, read-01 получает temporary-timeout от primary-a, один раз повторяется на primary-b и получает ok. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется primary-c, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.
Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает Retry-After как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.
Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.
\nДля проектирования разделите бюджет на четыре поля:
\nБюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.
\nRetry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.
\nОфициальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации maxAttempts задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.
У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом retryEnabled: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.
Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, PUT и DELETE, но допускает повтор POST только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.
Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.
\nУкажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте logical_id, attempt, owner, outcome, выбранное направление и причину остановки.
Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.
\nnode <<'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 присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.
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 или третьей реплики, ограничение стоит слишком поздно либо другой слой повторяет работу.
Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.
\n| Наблюдение | Проверяемая гипотеза | 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 | Повторить оба запуска на одинаковой нагрузке |
maxAttempts, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.logical_id, attempt, owner, outcome, направление и причину остановки.Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.
\nРавным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. Retry-After следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.
Fallback не всегда безопаснее ошибки. Урезанный результат подходит для необязательного блока, но может скрыть устаревшие или неполные данные. Для критичной команды лучше вернуть явный отказ и сохранить её для повторной обработки с идемпотентным ключом. Политику деградации должен принять владелец продукта и операции, а не библиотека retry.
\nНаконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.
\nДля каждого класса операций есть заполненные owner, retryable outcomes, maxAttempts, deadline, backoff, maxFanout, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.
Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.
\n503 Service Unavailable и Retry-After. Документ описывает семантику протокола, но не решает за приложение вопрос о безопасном повторе.maxAttempts, backoff, jitter, transparent retry, throttling и метриках. Настройки специфичны для gRPC и требуют проверки версии клиента.