Паттерн Circuit Breaker: предотвращайте каскадные сбои в распределённых системах

Пять минут падения S3 в AWS может парализовать тысячи приложений. Но чаще всего катастрофа начинается не с облачного провайдера, а с вашего кода. Один неотказоустойчивый вызов к внешнему API способен запустить цепную реакцию: исчерпание соединений БД, накопление задач в очередях, перегрузка памяти. Этого можно избежать, если внедрить механизм автоматической изоляции сбоев — Circuit Breaker.

Как работает Circuit Breaker: не просто «разомкнутая цепь»

Представьте автоматический выключатель, который физически разрывает цепь при перегрузке. В программировании Circuit Breaker — конечный автомат с тремя состояниями:

  1. Закрыто (Closed)
    Запросы проходят нормально. Считаем ошибки: если превышен порог (например, 5 ошибкок за 30 секунд) — переходим в состояние «Разомкнуто».

  2. Разомкнуто (Open)
    Все запросы мгновенно отклоняются без реального выполнения. Через таймаут (например, 30 секунд) переходим в «Полуразомкнуто».

  3. Полуразомкнуто (Half-Open)
    Пропускаем ограниченное количество запросов. Если они успешны — закрываем цепь, если нет — снова размыкаем.

Реализация на Node.js: не только для AWS SDK

Рассмотрим практический пример на TypeScript. Вместо готовой библиотеки создадим ядро Circuit Breaker, чтобы понимать внутреннюю механику:

typescript
type State = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

class CircuitBreaker {
  private state: State = 'CLOSED';
  private failureCount = 0;
  private nextAttemptTime = 0;

  constructor(
    private readonly failureThreshold: number,
    private readonly recoveryTimeout: number,
    private readonly halfOpenAttempts: number
  ) {}

  async exec<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttemptTime) {
        throw new Error('Circuit open');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await fn();
      this.#handleSuccess();
      return result;
    } catch (error) {
      this.#handleFailure();
      throw error;
    }
  }

  #handleSuccess() {
    this.failureCount = 0;
    if (this.state === 'HALF_OPEN') {
      this.state = 'CLOSED';
    }
  }

  #handleFailure() {
    this.failureCount++;
    if (
      this.state === 'CLOSED' &&
      this.failureCount >= this.failureThreshold
    ) {
      this.state = 'OPEN';
      this.nextAttemptTime = Date.now() + this.recoveryTimeout;
    } else if (this.state === 'HALF_OPEN') {
      this.state = 'OPEN';
      this.nextAttemptTime = Date.now() + this.recoveryTimeout;
    }
  }
}

Применение с axios:

typescript
const paymentServiceBreaker = new CircuitBreaker(5, 30_000, 3);

async function chargeUser(userId: string, amount: number) {
  return paymentServiceBreaker.exec(async () => {
    const response = await axios.post('https://api.payment.com/charge', {
      userId,
      amount,
    }, { timeout: 10_000 });
    return response.data;
  });
}

Где разработчики ошибаются: тонкие грани

Ошибка 1: Глобальный Circuit Breaker для всех эндпоинтов
Если объединять вызовы к авторизации и генерации PDF в один контур, то сбой медленного PDF-сервиса заблокирует авторизацию. Изолируйте контуры по типам операций и URI.

Ошибка 2: Игнорирование таймаутов
Даже с Circuit Breaker запросы должны иметь разумные timeout. Не ждите 60 секунд ответа от «умершего» сервиса — освобождайте Event Loop.

typescript
// Плохо: без таймаута в вызове
axios.get('https://api.example.com/data');

// Хорошо: явное ограничение времени
axios.get('https://api.example.com/data', { timeout: 2500 });

Ошибка 3: Полуразомкнутое состояние без ограничения количества запросов
Если в HALF_OPEN пропускать все запросы, внезапная нагрузка на ещё не восстановившийся сервис вызовет повторный сбой. Используйте алгоритм «разрешить не более N запросов за период».

Интеграция с метриками: что нельзя упустить

Circuit Breaker бесполезен, если вы не видите его состояние. Экспортируйте метрики:

  • Переключения состояния
  • Количество отклонённых запросов
  • Успешные/неуспешные проверки в HALF_OPEN

Пример с Prometheus:

typescript
import client from 'prom-client';

const stateGauge = new client.Gauge({
  name: 'circuit_breaker_state',
  help: 'Current state (0-Closed, 1-Open, 2-HalfOpen)',
  labelNames: ['service'],
});

// В методе exec:
stateGauge.set({ service: 'payments' }, 
  this.state === 'CLOSED' ? 0 : this.state === 'OPEN' ? 1 : 2);

Тестируйте как в бою: симулируйте реалистичные сбои

Юнит-тесты проверяют логику переходов, но реальные проблемы возникают в продакшене. Используйте Chaos Engineering:

  1. Симуляция задержек
    Замедлите 10% запросов к платежному сервису на 8 секунд — увидите, как Circuit Breaker предотвращает исчерпание потоков в Node.js.

  2. Эталонные тесты
    Запустите нагрузочный тест: 1000 RPM к вашему API. Без Circuit Breaker латентность вырастет экспоненциально, с ним — запросы начнут отклоняться, сохраняя среднее время ответа.

Когда не нужен Circuit Breaker?

  • Для неключевых операций
    Если сбой функции генерации аватаров пользователя не влияет на ядро приложения, возможно, достаточно Retry с экспоненциальной отсрочкой.

  • Когда вызывающая сторона обрабатывает сбои
    Сервис, который только возвращает статичные данные? Вероятно, Retry Pattern и кэширование предпочтительнее.

Circuit Breaker — это не серебряная пуля, а часть стратегии устойчивости. Комбинируйте его с Retry, Rate Limiting и балансировкой нагрузки, но помните: главная цель — дать системе «передохнуть», а не скрыть проблемы. Каждый переход в OPEN должен быть триггером для исследования причины, а не просто автоматическим костылём.