open atlas
↑ К треку
React с нуля до senior RCT · 09 · 02

Восстановление и деградация: сброс состояния, ретраи с backoff, честные отказы

Восстановление — сброс состояния: resetKeys или reset() перемонтируют упавшее поддерево, и испорченное состояние умирает. Транзиентные сбои ретраим с backoff и джиттером, не в цикле. Деградируем повиджетно, страница живёт; роняем её целиком только ради целостности.

RCT Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

API котировок просел на две минуты — повышенные 5xx, ничего драматичного. Драму устроил клиент. У виджета курсов в трейдинговом дашборде был boundary с кнопкой Retry, и Retry был подключён как «вызови fetch ещё раз, немедленно, на каждый клик и на каждую ошибку». Тридцать тысяч открытых дашбордов начали в унисон долбить деградировавший API, и каждый сбой планировал следующую попытку ровно через секунду. Графики нагрузки показывают: API получал восьмикратный трафик от собственных клиентов в тот самый момент, когда меньше всего мог его себе позволить — двухминутный брауноут растянулся в сорокаминутный отказ, устроенный кодом восстановления. А в том же виджете прятался второй баг: когда кривой тик его ронял, клик по Retry перерисовывал то же дерево поверх того же отравленного Redux-состояния и падал снова за один кадр. Пользователи кликали Retry, смотрели, как мигает фолбэк, и заводили тикеты «кнопка ретрая декоративная». Два урока в одном инциденте: восстановление — это выселение состояния, которое упало, а ретрай — это умение осознанно не ретраить сразу.

К концу урока вы поймёте, почему наивная кнопка Retry может усилить отказ в восемь раз, и разберётесь в точной механике выселения состояния, которая делает reset настоящим.

Восстановление — выселение состояния, а не ререндер

Когда boundary ловит ошибку, поддерево под ним размонтируется — но причина краша обычно живёт дальше: в состоянии родителя, в сторе, в URL-параметре. Краш — симптом; испорченное или неожиданное состояние — болезнь. Поэтому «retry» не может означать «отрисуй то же самое ещё раз» — то же состояние, тот же рендер, то же исключение, часто внутри одного кадра. Восстановление — это перемонтирование поддерева с выселением или заменой отравленного состояния.

react-error-boundary даёт оба направления этого хода:

<ErrorBoundary
  FallbackComponent={QuoteFallback}
  resetKeys={[symbol]}          // symbol меняется → boundary сбрасывается, поддерево перемонтируется
  onReset={() => refetchQuote()} // вместе с краш-состоянием убрать и причину
>
  <QuotePanel symbol={symbol} />
</ErrorBoundary>

resetKeys — декларативное направление: когда любое значение массива меняется — пользователь выбрал другой тикер, обновился параметр роута — boundary выходит из состояния ошибки и перемонтирует детей. Краш был про старый ввод, поэтому новый ввод — естественный триггер восстановления. resetErrorBoundary() (передаётся фолбэку) — императивное направление: кнопка Retry. Но её нажатие лишь снимает флаг ошибки самого boundary и перемонтирует детей со свежим локальным состоянием — если яд живёт вне поддерева, причину нужно убирать дополнительно в onReset: рефетч, чистка кэш-записи, сброс среза стора. Чистый React без библиотеки делает то же самое через key: поднимите key={attempt} на поддереве — React размонтирует старый экземпляр вместе со всем состоянием и смонтирует новый. Перемонтирование и есть суть: состояние испорченного ребёнка должно умереть, а идентичность по key — то, чем React решает, что умирает.

Викторина

Кнопка Retry в фолбэке вызывает resetErrorBoundary(). Виджет падает снова за один кадр, каждый раз. Каков наиболее вероятный механизм?

Ретрай с backoff — или станьте вторым отказом

Когда вы добавляете кнопку Retry, вы принимаете решение из распределённых систем от имени каждого клиента, который откроет эту страницу одновременно с остальными. Транзиентные сбои — 503, оборвавшееся соединение, брауноут — заслуживают ретрая. Но инцидент из Hook — стандартный режим отказа наивного ретрая: тысячи клиентов, ретраящих с одинаковым фиксированным интервалом, образуют синхронизированную волну (thundering retry), и добавленная вами нагрузка приходит ровно тогда, когда сервис меньше всего способен её переварить. Клиентский ретрай — это решение из распределённых систем, принятое в UI-коде. Дисциплина та же, что на бэкенде:

async function retryFetch(url, { tries = 3, base = 1000, cap = 30_000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetch(url);
      if (res.status >= 500) throw new Error("HTTP " + res.status);
      return res;
    } catch (err) {
      if (attempt + 1 >= tries) throw err;             // бюджет исчерпан → boundary
      const delay = Math.min(cap, base * 2 ** attempt); // 1с, 2с, 4с … с потолком
      await new Promise((r) => setTimeout(r, delay + Math.random() * delay)); // джиттер
    }
  }
}

В этих десяти строках спрятаны три правила. Экспоненциальный backoff размазывает волну по времени — 1с, 2с, 4с вместо метронома. Джиттер рассинхронизирует клиентов — без случайного слагаемого тридцать тысяч дашбордов, упавших вместе, будут вечно ретраить вместе. Бюджет ретраев завершает историю: после бюджета бросаем в boundary и отдаём следующую попытку действию в человеческом темпе — кнопке Retry; кликающий пользователь естественно ограничен по частоте и естественно «джиттерится». И ретраить стоит только правдоподобно транзиентное: 503 — да, 400 от кривого запроса — нет; ретрай детерминированного сбоя — это цикл с лишними шагами.

Частичная деградация: error budget в применении к пикселям

Виджетный boundary делает больше, чем ловит, — он превращает краш в деградированный режим, пока остальная страница живёт. Это мышление error budget, применённое к UI: не у каждой области экрана одинаковые требования к доступности, значит, не каждая область должна падать с одинаковым радиусом поражения. Рельса рекомендаций может схлопнуться в пустой слот — и никто не потеряет денег. Виджет котировок может показать последний удачный тик с заметной пометкой «устарело» — это лучше и спиннера, и дыры. Вопрос уровня страницы: какие области декоративные, какие деградируемые со stale-данными, а какие критичны для целостности?

Сам фолбэк должен пользователю три правила честности. Сказать, что сломалось, на языке пользователя — «Котировки недоступны» лучше, чем «Что-то пошло не так», и оба лучше бесконечного спиннера, который врёт о прогрессе. Предложить настоящее следующее действие — Retry, проведённый через сброс с выселением причины, а не декоративный. И оставить выходы живыми — навигацию, остальную страницу, уже введённые данные. Фолбэк, запирающий пользователя, — второй отказ поверх первого.

И есть случай, когда частичная живучесть — неверная цель. Если сломалось критичное для целостности — auth-контекст, решающий, чьи данные рендерить; платёжный шаг, решающий, сколько списать; итог корзины, питающий заказ, — вежливая деградация вокруг рискует показать пользователю A сессию пользователя B или списать по устаревшей сумме. Здесь страницу роняют намеренно: полностраничный фолбэк, без бодрого «некоторые функции могут быть недоступны». Доступность — это фича; корректность — это продукт.

Викторина

Во время брауноута API котировок какая клиентская политика ретраев добавляет меньше всего нагрузки в худший момент и при этом быстро восстанавливается?

Почему это работает

Почему перемонтировать, а не попросить упавший компонент починить себя? Потому что React не может знать, какая часть состояния поддерева участвовала в краше — редьюсер, выдавший NaN, мемоизированный селектор с отравленным срезом в кэше, ref с мёртвым DOM-узлом. Любое из этого может снова вызвать исключение. Размонтирование — единственная операция с гарантией: всё локальное состояние, эффекты и ref старого экземпляра исчезают, новый монтаж стартует с пропсов и initial state — та же логика, что у перезапуска процесса в supervision-деревьях (эрланговское «let it crash»), а не починка на месте. Это же объясняет предел reset: перемонтирование вычищает только состояние внутри boundary. Состояние выше него — сторы, кэши, URL — выживает по дизайну; именно поэтому существует onReset и именно поэтому reset без выселения причины зацикливается.

Вспомните перед уходом
  1. 01
    Объясните, почему восстановление после пойманного краша требует перемонтирования, и назовите два триггера, которые даёт react-error-boundary.
  2. 02
    Сформулируйте три правила клиентского ретрая и страничное решение о деградации, которое задаёт им рамку.
Итог

Пойманная boundary ошибка — начало восстановления, а не конец. Краш обычно симптом состояния — в поддереве, в сторе, в URL-параметре, — поэтому ререндер того же дерева поверх того же состояния бросает снова в пределах кадра; настоящее восстановление — выселение состояния, и перемонтирование — то, как React выселяет: состояние, эффекты и ref старого экземпляра уничтожаются, новый монтаж стартует с начальных значений. react-error-boundary упаковывает оба триггера — resetKeys автосбрасывается, когда меняются входы, про которые был краш, а resetErrorBoundary даёт фолбэку императивный Retry, — тогда как onReset — место для выселения причин, живущих выше boundary, ведь перемонтирование вычищает только то, что внутри. Ретрай нижележащей операции — решение из распределённых систем, принятое в UI-коде: ретраим только правдоподобно транзиентное, разносим попытки экспоненциальным backoff с джиттером, чтобы синхронные волны клиентов не добили деградировавший API до настоящего отказа, и останавливаемся после небольшого бюджета, передавая темп человеку через фолбэк. Вокруг всего этого — карта деградации страницы, error budget в применении к пикселям: декоративные области падают в пустые слоты, деградируемые показывают заметно устаревшие данные, а фолбэк должен пользователю тройную честность: назвать, что сломалось, предложить Retry, который реально выселяет причину, и сохранить живыми навигацию и введённые данные. Исключение подтверждает дизайн: когда сломан компонент, критичный для целостности — auth-контекст, платёжный шаг, итог корзины, — частичная живучесть и есть опасный вариант, и осознанно уронить страницу целиком — решение сеньора, потому что доступность — это фича, а корректность — продукт. Теперь, когда вы пишете кнопку Retry, вы потянетесь за onReset для выселения причины, за экспоненциальным backoff с джиттером для защиты сервиса и за бюджетом, который передаёт следующую попытку пользователю, — а не будете делать tight loop.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.