{ "index": 68, "slug": "editorial-2026-02-mechanism-resilience", "title": "Механика устойчивости: сначала ограничьте каскад, потом настраивайте retry", "excerpt": "Timeout ограничивает ожидание одного вызова, но не объём дополнительной работы. Разбираем, как связать retry, fan-out, fallback и точку остановки в одну проверяемую модель.", "contentHtml": "
Внешний сервис начинает отвечать медленно, очередь на входе растёт, а собственное приложение расходует соединения на незавершённые запросы. Gateway повторяет вызов после timeout, адаптер делает ещё одну попытку, а балансировщик параллельно отправляет работу на несколько реплик. Каждый механизм по отдельности выглядит разумно. Вместе они могут превратить временный сбой в каскад: перегруженная зависимость получает новые запросы именно тогда, когда ей нужно уменьшить нагрузку.
\nГлавный вопрос здесь не «какое значение поставить для timeout». Сначала нужно определить, сколько дополнительной работы может породить один логический запрос, кто имеет право повторять вызов и где маршрут обязан остановиться. Только после этого выбирают задержку между попытками, реакцию на 503 и режим деградации. Иначе разные слои незаметно складывают свои политики: три попытки на клиенте и три на gateway дают до девяти вызовов одной зависимости ещё до учёта fan-out.
\nДалее используется маленькая фиксированная модель. Она считает только структуру маршрута: два логических запроса, максимум две попытки на каждый и один выбранный адрес за попытку. В ней нет сети, часов, очереди или измерения latency. Такой пример помогает увидеть границу и проверить отрицательный путь, но не подбирает значения для настоящего сервиса.
\nЕдиница расчёта — логический запрос пользователя или вызывающего сервиса. Один такой запрос может породить несколько исходящих вызовов. Попытка — один запуск зависимости; повтор добавляет следующую попытку после разрешённого исхода. Fan-out, или ширина разветвления, отвечает на другой вопрос: сколько направлений запускаются для одной попытки. Список из пяти реплик сам по себе не означает пять вызовов.
\nFallback также нельзя смешивать с retry. Он меняет результат: вместо полного ответа возвращает, например, короткую сводку из локального кеша. Если fallback сначала ищет данные в ещё трёх местах или запускает собственный retry, это уже новая работа, которую надо включить в расчёт. Точка остановки задаёт момент, после которого никакая ветвь не имеет права расширить маршрут.
\n| Величина | Значение в примере | Что она ограничивает | Что не следует из значения |
|---|---|---|---|
| Логические запросы | 2 | Объём сравниваемой нагрузки | Реальный QPS и размер очереди |
| Владелец retry | gateway | Единственное право создать повтор | Правильность выбора самого gateway |
| Максимум попыток | 2, включая исходную | Последовательное размножение вызовов | Безопасность повторения операции |
| Максимальный fan-out | 1 | Число адресов на одну попытку | Доступность и равномерность реплик |
| Fallback | cached-summary, 1 work unit | Цена сокращённого результата | Его полезность для продукта |
| Точка остановки | до route expansion | Запуск новых ветвей после нулевого бюджета | Отмену уже начатого сетевого вызова |
Для верхней границы в этой игрушечной схеме достаточно перемножить независимые множители: 2 логических запроса × 2 попытки × 1 направление = 4 route attempts. Это число не является прогнозом нагрузки. Оно отвечает на узкий вопрос: не появилась ли в конфигурации скрытая ветвь, которой нет в контракте.
\nПредставим один вызов с временным отказом. Gateway ждёт до своего deadline и создаёт второй вызов. Если адаптер внутри gateway тоже считает тот же отказ разрешением на повтор, один внешний retry превращается ещё в несколько внутренних. Если оба слоя выбирают две реплики, число запросов растёт дополнительно. При этом исходная причина — перегрузка или задержка — никуда не исчезает.
\nУ политики должен быть один владелец. Остальные слои могут передавать deadline, отмену и контекст попытки, но не должны тайно запускать собственный цикл. Если владельцев несколько по архитектурной необходимости, это надо считать произведением и ограничивать как отдельный контракт. Фраза «везде всего по две попытки» не описывает верхнюю границу, пока не указано, где заканчивается одна операция и начинается другая.
\nconst assert = require('node:assert/strict');\n\nconst policy = {\n logicalRequests: 2,\n maxAttempts: 2, // первая попытка уже входит в число\n maxFanout: 1,\n retryOwner: 'gateway',\n retryableStatuses: new Set([503]),\n fallback: { name: 'cached-summary', workUnits: 1 },\n};\n\nfunction assess(status, retryOwners) {\n if (retryOwners.length !== 1 || retryOwners[0] !== policy.retryOwner) {\n return { status: 'stop', reason: 'retry должен иметь одного владельца' };\n }\n\n const retryable = policy.retryableStatuses.has(status);\n const attempts = retryable ? policy.maxAttempts : 1;\n const routeAttempts =\n policy.logicalRequests * attempts * policy.maxFanout;\n\n return {\n status: 'ok',\n routeAttempts,\n terminal: retryable ? 'fallback:' + policy.fallback.name : 'response',\n };\n}\n\nassert.deepEqual(assess(503, ['gateway']), {\n status: 'ok',\n routeAttempts: 4,\n terminal: 'fallback:cached-summary',\n});\nassert.equal(assess(503, ['gateway', 'adapter']).status, 'stop');\nconsole.log(assess(503, ['gateway']));\nСохраните блок в файл cascade-check.cjs и запустите командой node cascade-check.cjs. В штатном пути он напечатает объект с четырьмя маршрутными попытками. Второй assert проверяет отрицательный путь: два владельца retry останавливают проверку до запуска какого-либо маршрута. Пример намеренно не вызывает HTTP-клиент; его задача — сделать правило видимым и воспроизводимым на чистом Node.js.
Timeout ограничивает время ожидания конкретного вызова. Он не сообщает, кто может повторить этот вызов и сколько дополнительной работы разрешено создать. Слишком короткое значение иногда увеличивает нагрузку: слой объявляет вызов неуспешным, пока зависимость продолжает обрабатывать его, а затем отправляет копию. Поэтому deadline должен распространяться по цепочке и учитывать отмену, а не превращаться в независимый таймер на каждом уровне.
\nHTTP 503 означает, что сервер временно не может обработать запрос; RFC 9110 допускает заголовок Retry-After как подсказку, сколько подождать перед следующим запросом. Это семантика ответа, а не готовое решение о повторе. Клиенту всё равно нужно сопоставить статус с идемпотентностью операции, остатком общего бюджета и допустимым результатом. Ошибку валидации или неверный запрос нельзя «лечить» повтором: одинаковый вход снова даст тот же постоянный исход.
\nBackoff снижает вероятность синхронной волны повторов, но не ограничивает их количество. Для повторов нужен конечный максимум, а для всего процесса — наблюдаемый budget. В качестве отдельных сигналов полезно видеть исходные вызовы, повторы, время ожидания, отмены и переход в fallback. Одна метрика ошибок без этих разрезов не показывает, что именно увеличивает нагрузку.
\nРежим деградации не означает «вернуть что-нибудь». Он меняет контракт для пользователя, поэтому его результат должен иметь имя, условие включения и понятную цену. В примере cached-summary возвращает неполную сводку после исчерпания попыток. Он не выбирает новые реплики и не запускает retry. Продуктовая команда отдельно решает, допустима ли такая сводка для конкретного экрана.
Проверяйте fallback как самостоятельный маршрут. Запишите его work units: чтение локального кеша, обращение к резервному хранилищу и сериализация ответа не обязательно стоят одинаково. Если резервный путь дороже основного или тоже зависит от перегруженного сервиса, его нельзя считать безопасной деградацией. Иногда правильный fallback — немедленный отказ с понятным кодом, а не ещё один поиск.
\nЛимит, проверяемый после выбора всех реплик, лишь сообщает о проблеме постфактум. К моменту проверки лишние вызовы уже заняли соединения. Сначала проверьте остаток бюджета, затем выберите следующий маршрут, а после ответа решите, нужен ли единственный разрешённый повтор. Когда бюджет равен нулю, результат должен быть terminal: ответ, fallback или ошибка. Новая попытка после terminal action — дефект контракта.
\nДля диагностики запишите переходы в trace: request → attempt-1 → temporary failure → budget-1 → attempt-2 → fallback. В опасном варианте будет видно другое: attempt-1 → replica-a + replica-b → adapter-retry → .... Такой trace помогает найти место размножения без спора о том, какой timeout «выглядит правильнее». Проверяйте порядок событий, а не только итоговый счётчик.
| Наблюдаемый симптом | Гипотеза | Воспроизводимая проверка | Ограниченное действие |
|---|---|---|---|
| После 504 исходящих вызовов становится больше | Повтор включён на двух слоях | Сопоставить retry owner в конфигурации и trace | Оставить один цикл, остальные слои сделать pass-through |
| Один request_id встречается на нескольких репликах сразу | Включён fan-out или hedging | Посчитать адреса до первого успешного ответа | Задать maxFanout и отдельно доказать безопасность копий |
| Fallback задерживает ответ | Он сам ищет данные и повторяет запрос | Развернуть его переходы и work units | Упростить до дешёвого результата или отключить режим |
| Лимит срабатывает, но нагрузка уже выросла | Проверка стоит после route expansion | Сравнить порядок trace transitions | Перенести точку остановки перед созданием ветвей |
| Два теста дают разные выводы | Различаются нагрузка или тип сбоя | Сверить logical load и failure injection | Сравнивать только одинаковые входы либо признать тесты несопоставимыми |
Формула из примера проверяет только число потенциальных маршрутных попыток. Она не учитывает время, параллелизм, уже выполняющиеся запросы, размер ответа, пропускную способность, rate limit, очередь, кэш-промахи и отмену на сервере. Четыре попытки в модели могут быть четырьмя последовательными вызовами или четырьмя одновременными — эти режимы имеют разный риск.
\nЧисла 2 и 1 выбраны для ручной проверки, а не для переноса в конфигурацию. В реальном сервисе предел зависит от стоимости операции, класса данных, SLO, ёмкости зависимости и допустимой потери результата. Для записи, платежа или другой операции с побочным эффектом одного timeout недостаточно, чтобы признать повтор безопасным. Нужны идемпотентный ключ, семантика сервера и проверка компенсации.
\nНе следует превращать статус ok из скрипта в обещание доступности. Он означает только, что фиксированная карточка удовлетворяет нескольким формальным условиям. Доказательство устойчивости требует измерения под нагрузкой, наблюдения за отменами и проверки поведения при частичном отказе. Если факт неизвестен, модель должна остановиться и назвать недостающую проверку.
Схема готова к инженерному испытанию, если можно без догадки ответить на пять вопросов: кто владеет retry; сколько попыток разрешено и как они считаются; сколько направлений может появиться на попытку; что именно возвращает fallback и сколько работы он добавляет; где прекращается создание новых ветвей. Для каждого ответа должен существовать trace или тестовый assert. Отдельно зафиксируйте, что в этом доказательстве не проверяется: реальная ёмкость, пользовательская ценность деградации и безопасность побочных эффектов.
\nПорядок важнее набора терминов. Сначала ограничьте дополнительную работу и сделайте её видимой. Затем настройте timeout, backoff и протокол ответа под измеренный сценарий. Такой маршрут не отменяет нагрузочные испытания, но не даёт локальной настройке retry скрыть каскад за красивым числом попыток.
\nmaxAttempts, списком повторяемых статусов, backoff и throttling. В документе максимум попыток включает исходный RPC; proposal не является общей политикой для HTTP и других стеков.