Files
progcode/editorial/agent-rewrites/067.json
T

8 lines
24 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 67,
"slug": "editorial-2026-02-field-resilience",
"title": "Retry под нагрузкой: как остановить каскад запросов",
"excerpt": "Медленная зависимость сама по себе не валит сервис: его валят неограниченные повторы, fan-out и поздний fallback. Разбираем бюджет попыток, проверку трассы и безопасную деградацию.",
"contentHtml": "<p>Внешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.</p>\n<p>Ниже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.</p>\n<figure><img src=\"/assets/editorial/2026/resilience-2026-failure-cascade-graph.svg\" alt=\"Один логический запрос проходит через ограниченный retry, выбор одной реплики и именованный fallback; после исчерпания бюджета поток останавливается\" loading=\"lazy\" /><figcaption>Одна логическая операция должна иметь видимую границу: повтор, выбор направления, fallback и окончательное действие не могут бесконечно порождать новые вызовы.</figcaption></figure>\n<h2>Сначала отделим симптом от механизма</h2>\n<p>Симптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.</p>\n<p>Полезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, <code>read-01</code> получает <code>temporary-timeout</code> от <code>primary-a</code>, один раз повторяется на <code>primary-b</code> и получает <code>ok</code>. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется <code>primary-c</code>, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.</p>\n<p>Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает <code>Retry-After</code> как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.</p>\n<h2>Считаем физическую работу</h2>\n<p>Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.</p>\n<p>Для проектирования разделите бюджет на четыре поля:</p>\n<ul><li><strong>maxAttempts</strong> — сколько всего попыток разрешено одной операции; в этой статье первая попытка входит в число.</li><li><strong>retryable outcomes</strong> — только те исходы, для которых повтор имеет смысл и безопасен.</li><li><strong>maxFanout</strong> — сколько направлений может выбрать одна попытка; значение 1 означает одну реплику, а не «перебрать список».</li><li><strong>terminal action</strong> — что вернуть или записать после остановки: деградированный результат, явную ошибку или задачу на последующую обработку.</li></ul>\n<p>Бюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.</p>\n<h2>Retry, fallback и hedging — разные операции</h2>\n<p>Retry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.</p>\n<p>Официальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации <code>maxAttempts</code> задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.</p>\n<p>У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом <code>retryEnabled</code>: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.</p>\n<h2>Контракт: исход, deadline и идемпотентность</h2>\n<p>Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, <code>PUT</code> и <code>DELETE</code>, но допускает повтор <code>POST</code> только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.</p>\n<p>Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.</p>\n<p>Укажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте <code>logical_id</code>, <code>attempt</code>, <code>owner</code>, <code>outcome</code>, выбранное направление и причину остановки.</p>\n<h2>Воспроизводимый пример без сети</h2>\n<p>Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.</p>\n<pre><code>node &lt;&lt;'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) &amp;&amp;\n attempt &lt; 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 &lt; 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</code></pre>\n<p>Ожидаемый вывод — две попытки для <code>read-01</code>, одна ветка fallback для <code>read-02</code> и две попытки с <code>fail-fast</code> для <code>read-03</code>. В этом примере <code>maxFanout</code> присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.</p>\n<h2>Как читать трассу</h2>\n<p>Trace — путь запроса через приложение. Для этой задачи он должен отвечать на пять вопросов: какой логический запрос начался, сколько было попыток, кто разрешил каждую, какой исход получен и где создан terminal action. OpenTelemetry разделяет traces, metrics и logs; не стоит подменять отсутствующую связь между попытками общей метрикой latency.</p>\n<p>Минимальная полезная последовательность выглядит так: <code>read-01 / attempt=1 / edge / primary-a / temporary-timeout</code>; затем <code>read-01 / attempt=2 / edge / primary-b / ok</code>. Для <code>read-03</code> последняя запись должна содержать <code>attempt=2</code> и <code>fail-fast</code>. Если после неё виден вызов fallback или третьей реплики, ограничение стоит слишком поздно либо другой слой повторяет работу.</p>\n<p>Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.</p>\n<h2>Матрица симптомов и действий</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика каскада по одному logical request</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Проверяемая гипотеза</th><th scope=\"col\">Evidence</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Физических вызовов больше, чем пользовательских операций</td><td>Retry включён на нескольких слоях</td><td>Сверить <code>owner</code> и <code>attempt</code> в trace</td><td>Оставить одного владельца или ввести общую квоту</td></tr><tr><td>Один timeout обращается ко всем репликам</td><td>Fan-out не ограничен на попытку</td><td>Посчитать направления для одного <code>logical_id</code></td><td>Задать целочисленный <code>maxFanout</code> и проверить его до выбора</td></tr><tr><td>Fallback вызывает ту же зависимость повторно</td><td>Запасная ветка содержит скрытый retry</td><td>Найти дочерние spans после <code>fallback</code></td><td>Запретить повтор или включить вызовы в общий бюджет</td></tr><tr><td>Последний span закрыт, но очередь продолжает расти</td><td>Нет отмены in-flight работы или load shedding</td><td>Сопоставить deadline, cancellation и queue depth</td><td>Остановить позднюю работу и отклонять нагрузку раньше</td></tr><tr><td>Нельзя объяснить, почему повтор разрешён</td><td>Outcome классифицируется по общему 5xx</td><td>Сверить исход, метод и семантику операции</td><td>Разделить retryable и terminal outcomes</td></tr><tr><td>После исправления цифра лучше, но сценарий изменился</td><td>Сравниваются разные входы</td><td>Проверить failure injection и baseline key</td><td>Повторить оба запуска на одинаковой нагрузке</td></tr></tbody></table></div>\n<h2>Порядок безопасной проверки</h2>\n<ol><li>Опишите один логический запрос и допустимый результат. Не смешивайте его с количеством сетевых вызовов.</li><li>Назовите failure injection: зависимость, исход, длительность и область действия.</li><li>Найдите всех владельцев retry в клиенте, SDK, адаптере, gateway и очереди.</li><li>Для каждого retryable исхода проверьте идемпотентность или ключ, который защищает повтор.</li><li>Задайте общий deadline, <code>maxAttempts</code>, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.</li><li>Ограничьте fan-out на одну попытку. Для hedging отдельно проверьте отмену проигравших вызовов.</li><li>Опишите fallback и terminal action. После terminal action не должно быть скрытого retry.</li><li>Добавьте в trace <code>logical_id</code>, <code>attempt</code>, <code>owner</code>, <code>outcome</code>, направление и причину остановки.</li><li>Выполните положительный и отрицательный сценарии: восстановление, fallback, исчерпание бюджета, второй retry owner и неограниченный fan-out.</li><li>Сравните результат с тем же сигналом, который обнаружил проблему: количество физических вызовов, очередь, latency и долю ошибок.</li></ol>\n<h2>Что нельзя обещать по этому примеру</h2>\n<p>Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.</p>\n<p>Равным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. <code>Retry-After</code> следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.</p>\n<p>Fallback не всегда безопаснее ошибки. Урезанный результат подходит для необязательного блока, но может скрыть устаревшие или неполные данные. Для критичной команды лучше вернуть явный отказ и сохранить её для повторной обработки с идемпотентным ключом. Политику деградации должен принять владелец продукта и операции, а не библиотека retry.</p>\n<p>Наконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.</p>\n<h2>Критерий готовности</h2>\n<p>Для каждого класса операций есть заполненные owner, retryable outcomes, <code>maxAttempts</code>, deadline, backoff, <code>maxFanout</code>, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.</p>\n<p>Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — разделы об идемпотентных методах, <code>503 Service Unavailable</code> и <code>Retry-After</code>. Документ описывает семантику протокола, но не решает за приложение вопрос о безопасном повторе.</li><li><a href=\"https://grpc.io/docs/guides/retry/\" target=\"_blank\" rel=\"noopener noreferrer\">gRPC: Retry</a> — официальная документация о retry policy, <code>maxAttempts</code>, backoff, jitter, transparent retry, throttling и метриках. Настройки специфичны для gRPC и требуют проверки версии клиента.</li><li><a href=\"https://sre.google/sre-book/addressing-cascading-failures/\" target=\"_blank\" rel=\"noopener noreferrer\">Google SRE Book: Addressing Cascading Failures</a> — рекомендации по ограничению retry на запрос, серверному бюджету, backoff, раннему отказу и тестированию перегрузки.</li><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — определения traces, metrics и logs, необходимые для разделения пути запроса, измерения и записи событий.</li></ul>"
}