open atlas
← Все проекты

backend · intermediate · 4d

Прерыватель цепи

Собери прерыватель цепи, который прекращает долбить падающую зависимость, безопасно прощупывает её в состоянии half-open и автоматически восстанавливается — именно этот паттерн не даёт каскаду микросервисов превратить один плохой узел в полный простой.

Прерыватель цепи — аналог электрического предохранителя в распределённых системах: он прекращает распространение сбоев от одного сервиса к его вызывающим, когда частота сбоев пересекает порог, затем осторожно прощупывает перед повторным допуском нагрузки. Сборка такого прерывателя с нуля заставляет столкнуться с ключевыми противоречиями — последовательное vs частотное срабатывание, жадный vs ленивый переход состояний, оптимистичная vs консервативная стратегия зонда — а дисциплина инжектированных часов делает всё это юнит-тестируемым без sleep в тестах.

Результат

Прерыватель цепи на конечном автомате, где повторные сбои открывают цепь, временной зонд переводит её в half-open, успешный зонд сбрасывает в closed, а неудачный зонд снова открывает с новым таймером восстановления — верифицировано детерминированными инжектированными часами, без Date.now().

Этапы

0/6 · 0%
  1. 01Счётчик сбоев: учитывай последовательные отказы и переводи в open

    Единственная задача прерывателя в состоянии closed — считать последовательные сбои и срабатывать, когда они превышают порог. Ключевая тонкость — «последовательные»: успех сбрасывает счётчик в ноль, так что один удачный вызов поглощает соседние сбои. Порог (скажем, 5) — это параметр настройки: слишком низкий — и транзиентный сбой без нужды открывает цепь; слишком высокий — и реально сломанная зависимость получает N повторов до отсечения потока. На этом этапе реализуй именно это: счётчик, инкрементирующийся при каждом сбое, сбрасывающийся при успехе и переводящий в 'open' по достижении порога. Короткого замыкания пока нет — это следующий этап. Сначала сделай правильный скелет конечного автомата: состояния 'closed', 'open', 'half-open'; на этом этапе только 'closed' принимает вызовы. Сделай порог и длительность open конфигурируемыми, а часы инжектируй параметром каждого вызова, а не захватывай при конструировании — именно это делает всю систему юнит-тестируемой без мокирования Date.now().

    Критерии готовности
    • Последовательные сбои до порога сохраняют состояние 'closed', а сбой на пороге переводит в 'open' — но состояние open пока не делает короткого замыкания вызовов.
    • Успех в любой точке последовательности сбрасывает счётчик сбоев, а порог и длительность open — параметры конструктора, а не магические числа.
    Самопроверка

    Проведи счётчик через: 2 сбоя, 1 успех, ещё 3 сбоя при пороге 3 — покажи, какое состояние и значение счётчика ты держишь после каждого вызова; senior-ревьюер проверяет, что успех сбрасывает до нуля, а третий пост-ресетный сбой срабатывает, а не пятый общий.

  2. 02Состояние open: короткое замыкание без вызова обёрнутой функции

    Как только цепь открыта, каждый вызов должен провалиться быстро, не касаясь зависимости — весь смысл паттерна в том, чтобы перестать усиливать нагрузку на то, что уже сломано. Прерыватель бросает исключение немедленно, и — что критично — вообще не вызывает обёрнутую функцию: открытая цепь — это обещание, что зависимость получит ноль запросов в окне восстановления. Это важно, потому что каждый вызов в адрес борющегося сервиса добавляет латентность, занимает поток или соединение и потенциально замедляет восстановление. Измерь короткое замыкание, верифицировав, что счётчик вызовов обёрнутой функции остаётся нулём, пока прерыватель открыт. Брошенная ошибка должна быть отличима от ошибок самой обёрнутой функции, чтобы вызывающая сторона могла решить — деградировать изящно или пробросить: универсального Error недостаточно; типизированный CircuitOpenError (или флаг на ошибке) даёт вызывающей стороне этот выбор. Окно восстановления (openMs) стартует с момента срабатывания, а не с момента последнего вызова.

    Критерии готовности
    • В состоянии open до истечения окна восстановления (now < openedAt + openMs), call() бросает исключение без вызова обёрнутой функции — верифицировано счётчиком вызовов, остающимся нулём.
    • Ошибка, бросаемая в состоянии open, отличима от ошибки обёрнутой функции (класс CircuitOpenError или типизированный дискриминант), чтобы вызывающая сторона могла обрабатывать каждый случай отдельно.
    Самопроверка

    Покажи тест, где три сбойных вызова срабатывают прерыватель, затем ещё десять вызовов в открытом состоянии — senior-ревьюер проверяет, что счётчик вызовов обёрнутой функции равен ровно 3 (те, что его сработали), а не 13, и что брошенная ошибка идентифицируема как состояние open цепи, а не универсальный Error.

  3. 03Зонд half-open: пропускай один вызов после окна восстановления

    Окно восстановления истекло, и ты не прыгаешь сразу обратно в closed — ты прощупываешь. Цепь в состоянии half-open пропускает ровно один вызов к зависимости: если он успешен, прерыватель сбрасывается в closed; если нет — снова открывается с таймером, сброшенным к now. Ограничение «ровно один» критично для нагрузки: если ты пропускаешь пачку вызовов в момент истечения окна, ты рискуешь снова сломать сервис, который только начал восстанавливаться. halfOpenMax контролирует, сколько параллельных зондов разрешено (обычно 1). Переход состояния по результату зонда должен быть атомарным с точки зрения вызывающей стороны: успешный зонд должен сбросить счётчик сбоев и перевести состояние в closed за один логический шаг, а не двумя отдельными записями. Правильно реализуй проверку времени: переход из open в half-open не событийный (никакой таймер не срабатывает) — он проверяется лениво при следующем вызове. state(now) возвращает 'half-open', когда now >= openedAt + openMs, а путь вызова проверяет state(now) перед принятием решения.

    Критерии готовности
    • state(now) возвращает 'half-open', как только now >= openedAt + openMs, и call(fn, now) в этот момент вызывает fn ровно один раз (зонд) вместо короткого замыкания.
    • Успешный зонд сбрасывает состояние в 'closed' со счётчиком сбоев ноль; неудачный зонд возвращает в 'open' с таймером восстановления, установленным в текущий now, а не в исходное время срабатывания.
    Самопроверка

    Покажи временную шкалу состояний: срабатывание при now=0 с openMs=1000, затем неудачный зонд при now=1000 — чему равен openedAt после повторного открытия? Senior-ревьюер проверяет, что openedAt сброшен в 1000, то есть окно восстановления отсчитывается от неудачного зонда, а не от исходного срабатывания.

  4. 04Полнота конечного автомата: все переходы, крайние случаи, повторный вход

    На данный момент у тебя три состояния и четыре перехода: closed→open (порог превышен), open→half-open (окно истекло, лениво), half-open→closed (зонд успешен), half-open→open (зонд неудачен). Проведи аудит машины на баги повторного входа: что происходит, если два вызывающих одновременно попадают в окно half-open? Первый зонд-вызов должен владеть переходом; второй не должен тоже зондировать — если halfOpenMax равен 1, второй вызов, пока зонд в полёте, должен либо делать короткое замыкание (считая in-flight как ещё открытое), либо ждать, в зависимости от дизайна. Сделай этот выбор явным и протестированным. Также проведи аудит пути сброса: после успеха зонда и перехода в closed следующие N сбоев должны снова срабатывать прерыватель с нуля — убедись, что счётчик сбоев был действительно сброшен, а не просто замаскирован. Нарисуй полный конечный автомат (в виде комментария в коде — нормально), назвав каждый переход и его защиту, чтобы будущий сопровождающий не реконструировал это из кода.

    Критерии готовности
    • Все четыре перехода состояний покрыты тестами, включая путь повторного открытия после зонда и повторное срабатывание после успешного сброса зонда.
    • Конечный автомат задокументирован в виде комментария или диаграммы с именованием каждого состояния, каждого перехода и его условия защиты — не оставлен неявным в реализации.
    Самопроверка

    Опиши, что делает твой прерыватель, если две goroutine/async вызова оба проверяют состояние при now >= openedAt + openMs до того, как любой зонд завершился — senior-ревьюер проверяет, что либо только один зонд достигает зависимости (halfOpenMax соблюдён), либо компромисс дизайна задокументирован.

  5. 05Скользящее окно сбоев: считай сбои во временном окне, а не подряд

    Счётчик последовательных сбоев имеет острый изъян: один успех посреди серии сбоев сбрасывает счётчик в ноль, поэтому зависимость, падающая в 80% случаев, может никогда не сработать прерыватель, если 20% успехов распределены. Скользящее окно считает сбои и общие вызовы за последние N секунд (кольцевой буфер или колесо вёдер), срабатывает при частоте сбоев >= порог% и гораздо точнее отражает реальное здоровье зависимости. Компромисс — память: окно 60 секунд с бакетами по 1 мс — это 60 000 счётчиков на экземпляр прерывателя; большинство реализаций используют 10–60 более грубых бакетов (каждый охватывает одну секунду). Реализуй скользящее окно как замену счётчика последовательных сбоев. Ключево: истечение бакета основано на времени: бакет 61-секундной давности просрочен и его сбои не считаются, но нельзя полагаться на фоновую зачистку — истекай лениво при поступлении нового вызова. Протестируй граничный случай: сбои, попадающие ровно на границу бакета (при возрасте окна == windowMs), не должны считаться.

    Критерии готовности
    • Прерыватель срабатывает по частоте сбоев (failures/total >= порог%) в скользящем окне, а не по последовательному счёту, и один успех вперемешку со сбоями не сбрасывает окно.
    • Истечение бакета ленивое (вызывается вызовом, не фоновым таймером), а сбои истёкшего бакета исключаются — протестировано продвижением инжектированных часов за границу окна.
    Самопроверка

    Покажи схему кольцевого буфера для 10-бакетного, 10-секундного окна: после продвижения инжектированных часов на 15 секунд — какие бакеты живые, какие истекли, и что происходит со счётчиками в уже истёкших бакетах? Senior-ревьюер проверяет, что ленивое истечение отбрасывает их при следующем вызове, а не в фоновой зачистке.

  6. 06Метрики и фолбэк: наблюдай за прерывателем, изящно обрабатывай open

    Прерыватель цепи, срабатывающий молча, — это простой без сигнала. Отправляй счётчики для: всех вызовов, сбоев, коротких замыканий (вызовов, отклонённых в open), успешных зондов, неудачных зондов и переходов состояний. Это числа, которые говорят, правильно ли настроен прерыватель: если число коротких замыканий нулевое, а сбои растут — порог слишком высок; если коротких замыканий огромное количество, а зависимость здорова — окно слишком длинное. Счётчики дешевы (только инкремент), поэтому отправляй их на каждом пути вызова. Для вызывающих ошибка circuit-open — возможность изящной деградации: вернуть кэшированный результат, значение по умолчанию или 503 с Retry-After, выведенным из openedAt + openMs - now. Спроектируй интерфейс Fallback, который прерыватель принимает опционально и вызывает, когда цепь открыта. Протестируй, что фолбэк срабатывает при открытой цепи и не срабатывает при закрытой — прерыватель не должен поглощать результат фолбэка как успех и непреднамеренно сбрасывать счётчик сбоев.

    Критерии готовности
    • Прерыватель предоставляет снимок метрик (total, failures, shortCircuits, probesSucceeded, probesFailed, currentState), корректно инкрементирующийся на всех путях вызова.
    • Опциональная функция фолбэка вызывается, когда цепь открыта (не когда закрыта), её возвращаемое значение доходит до вызывающей стороны и не засчитывается как успех зонда и не сбрасывает счётчик сбоев.
    Самопроверка

    Покажи снимок метрик после: 4 сбоев (порог=3), 2 коротких замыканий, одного успешного зонда — senior-ревьюер проверяет shortCircuits=2, probesSucceeded=1, failures=4, и currentState='closed', а также что результат фолбэка не появляется в счётчике probesSucceeded.

Стартер

  • README.md
  • src/breaker.ts
  • test/breaker.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Корректность конечного автомата Три состояния присутствуют в коде, но переходы имеют пробелы: успех в середине последовательности может не сбрасывать счётчик, или проверка open→half-open срабатывает по фоновому таймеру, а не лениво при следующем вызове. Все четыре перехода реализованы и протестированы; open→half-open ленивый (проверяется при вызове, не планируется); неудачный зонд сбрасывает таймер восстановления в now, а не в исходное время срабатывания; успешный зонд сбрасывает счётчик сбоев. Машина задокументирована (состояния, переходы, условия защиты) в виде комментария или диаграммы; повторный вход при конкуренции (два вызывающих гонятся за окном half-open) либо блокирован halfOpenMax, либо компромисс сформулирован; истечение скользящего окна ленивое и протестировано на границе.
Восстановление через часы (half-open) Восстановление использует setTimeout или Date.now() внутри, делая тесты зависимыми от wall-clock времени и непригодными для детерминированного CI. Часы инжектируются в каждый вызов (параметр `now`), state(now) возвращает корректное состояние для любого инжектированного timestamp, и Date.now() или таймеры в реализации отсутствуют. Инжектированные часы — единственный источник истины о времени везде; сброс таймера восстановления при неудачном зонде (openedAt = now в момент зонда, не в исходное время срабатывания) доказан тестом; набор тестов выполняется менее чем за 10 мс без вызовов sleep.
Учёт сбоев и наблюдаемость Сбои инкрементируют счётчик и счётчик срабатывает прерыватель; метрики не экспортируются; вызывающие не могут отличить ошибку circuit-open от ошибки обёрнутой функции. Типизированный CircuitOpenError отличает ошибки короткого замыкания от ошибок обёрнутой функции; снимок метрик экспонирует total, failures, shortCircuits, probesSucceeded, probesFailed и currentState. Метрики корректны на всех путях вызова (верифицировано проверкой снимка после скриптованной последовательности); частота сбоев скользящего окна заменяет последовательный счётчик; опциональный фолбэк срабатывает в open и его результат не портит счётчик зондов.
Эталонный разбор (спойлер)

Почему half-open вместо немедленного закрытия: прыжок прямо из open в closed после окна восстановления снова выставляет полную скорость трафика зависимости, которая могла восстановиться лишь частично. Единственный зонд на малом объёме подтверждает здоровье перед повторным допуском нагрузки, обменивая крошечную дополнительную задержку на значительно меньшую вероятность повторного срабатывания.

Инжектированные часы как дисциплина дизайна: передача `now` аргументом каждого вызова делает весь конечный автомат, зависящий от времени, детерминированным и юнит-тестируемым без sleep. Это не просто трюк для тестирования — та же дисциплина используется в event-sourced системах, где время является частью команды, а не неявным окружающим значением.

Последовательный vs скользящее-окно подсчёт сбоев: последовательный счётчик сбрасывается при любом успехе, поэтому зависимость, падающая в 80% случаев с 20% разрозненных успехов, может никогда не сработать прерыватель. Частота сбоев скользящего окна (failures / total за последние N секунд) отражает реальный сигнал здоровья и срабатывает при устойчивой деградации, а не только при изолированных всплесках.

Короткое замыкание никогда не должно вызывать обёрнутую функцию: вызов fn в состоянии open — даже для проверки его текущего состояния — обесценивает цель, потому что зависимость может испытывать нехватку памяти, исчерпание пула соединений или насыщение CPU, где любой дополнительный запрос замедляет восстановление. Гарантия «ноль вызовов fn в состоянии open» должна быть проверяема счётчиком вызовов в тестах, а не просто читаема из кода.

Retry-After для открытых цепей: при возврате ошибки вызывающей стороне включай оставшееся время восстановления (openedAt + openMs - now), чтобы вызывающая сторона могла установить заголовок Retry-After или запланировать повтор вместо немедленного повторного вызова прерывателя с впустую потраченным коротким замыканием.

Сделай по-сеньорски

  • Реализуй балкхед half-open: разрешай halfOpenMax параллельных зондов вместо ровно 1, отслеживай зонды в полёте и закрывай цепь только когда все зонды успешны — порассуждай, что означает закрытие на первом успехе (оптимистичное) против закрытия на всех успехах (консервативное) для частично восстановившейся зависимости.
  • Добавь адаптивные пороги: прерыватель отслеживает скользящий p99 латентности вместе с частотой сбоев и срабатывает при устойчивой высокой латентности, даже если запросы успешны — ведь зависимость, отвечающая за 30 с но возвращающая 200 OK, не здорова с точки зрения клиента.
  • Добавь координированное состояние прерывателя между несколькими инстансами через общее хранилище (Redis или Postgres): срабатывание одного прерывателя на одном инстансе немедленно распространяется на все инстансы, чтобы один плохой канареечный деплой защитил весь флот — замерь лаг распространения и окно расхождения состояний.
  • Напиши chaos-тест, оборачивающий реальный HTTP-сервер: запусти его здоровым, инжектируй 100% сбоев через N секунд, убедись, что прерыватель открывается в течение порог+1 запросов, затем исцели сервер и убедись, что прерыватель закрывается после успешного зонда — все assertions с wall-clock временем, без мокированных дат.

Навыки

finite state machinefail-fast patternhalf-open probeinjected clockobservability counters

Рекомендуемый стек

typescript