Learning Platform
Глоссарий Troubleshooting
Урок 10.03 · 22 мин
Начальный
Circuit breakerBulkheadFallbackResiliencepybreaker

Circuit breaker: не надоедайте мёртвому сервису

В предыдущем уроке мы научились retry с backoff: сервер вернул 429 или 503 — ждём и пытаемся снова. Это хорошо для коротких throttle (секунды-минуты). Но что если сервис партнёра упал серьёзно — на час, на день, на неопределённый срок?

Слепо ретраить — плохая идея. Каждый запрос: тратит наш CPU, держит соединения в pool, увеличивает latency наших операций (timeout × 5 retry = 50 секунд на каждую failed call). Хуже того — когда сервис начнёт восстанавливаться, тысячи накопленных клиентов одновременно его утопят повторно.

Решение — circuit breaker pattern, описанный Martin Fowler в 2014 году. Идея заимствована из электротехники: автомат, который размыкает цепь при перегрузке, чтобы не сжечь всё. В коде — компонент, который «запоминает», что upstream сломан, и временно возвращает ошибку без обращения к нему. Когда есть подозрение на восстановление — пропускает один пробный запрос. Если успех — открывает цепь обратно.

В этом уроке: три состояния circuit breaker, как настраивать пороги, bulkhead pattern для изоляции, fallback strategies, и когда circuit breaker не подходит.


Kafka consumer offset management: at-least-once vs exactly-once

Три состояния circuit breaker

Circuit breaker — это конечный автомат с тремя состояниями.

Состояния circuit breaker
ClosedНормальное состояние: запросы проходят, breaker считает успехи и ошибки. При накоплении ошибок выше threshold -- переход в Open
errors > threshold
OpenЦепь разомкнута: запросы НЕ доходят до upstream, сразу возвращается ошибка. Защищает upstream от нагрузки и клиента от долгих таймаутов
Half-openЧерез timeout (обычно 30-60 сек) -- пробуем один (или несколько) запросов. Если успех -- переход в Closed. Если снова ошибка -- обратно в Open

Полный цикл: Closed -> Open (накопили ошибки) -> Half-open (через timeout) -> Closed (если успех) или -> Open (если снова ошибка).


Sequence diagram: жизнь breaker

Покажем переходы через временную диаграмму.

Circuit breaker: переходы состояний
Client
Breaker
Upstream API
call_api()GET /data200 OKcall_api() x Nerrors >= threshold (5) -> Opencall_api()CircuitBreakerError (fast-fail)wait timeout 60scall_api()GET /data (probe)200 OK-> Closed

Главное преимущество видно на шаге m7: вместо ожидания timeout (10-30 секунд) и получения ошибки от мёртвого upstream — мгновенный fail в миллисекундах. Клиент быстрее понимает «надо использовать fallback», не висит.


Метрики и пороги

Circuit breaker отслеживает метрики и переходит в open при превышении порогов. Ключевые параметры:

Параметры circuit breaker
failure_thresholdСколько ошибок подряд (или какой процент за окно) триггерит open. Типично 5 ошибок подряд или 50% ошибок за минуту
recovery_timeoutСколько ждать в open до перехода в half-open. Слишком мало -- сервер не успеет восстановиться. Слишком много -- деградация дольше необходимого. Типично 30-60 сек
success_thresholdСколько успехов в half-open нужно, чтобы вернуть в closed. Иногда 1, иногда 3-5 для уверенности
error_classifierЧто считать ошибкой? Не все исключения -- failures. 4xx (404, 401) -- не ошибка upstream. 5xx и timeouts -- да

Грубо: «5 ошибок подряд -> open на 60 секунд -> half-open -> 1 успех -> closed». Это разумные defaults для большинства интеграций.


pybreaker: реализация в Python

pybreaker — стандартная Python-библиотека для circuit breaker. Decorator-based, простая.

pip install pybreaker

Базовое использование

import pybreaker
import requests

breaker = pybreaker.CircuitBreaker(
    fail_max=5,        # 5 ошибок -> open
    reset_timeout=60,  # через 60 секунд -> half-open
)

@breaker
def fetch_data(url: str):
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()


# Использование
try:
    data = fetch_data("https://api.example.com/data")
except pybreaker.CircuitBreakerError:
    # Breaker open -- upstream считается мёртвым
    print("Service unavailable, using fallback")
    data = get_fallback_data()
except requests.HTTPError as e:
    print(f"HTTP error: {e}")

После 5 подряд исключений в fetch_data breaker переходит в open. Следующие вызовы немедленно бросают CircuitBreakerError, не выполняя HTTP-запрос. Через 60 секунд — half-open, один пробный запрос. Если успех — closed.

Игнорировать определённые исключения

Не все ошибки означают «upstream сломан». 404, 401, 422 — это ошибки клиента или данных, не сервиса. Их не должны считать failures для breaker.

import pybreaker
import requests

class ClientError(Exception):
    """Не считать failures для breaker."""

breaker = pybreaker.CircuitBreaker(
    fail_max=5,
    reset_timeout=60,
    exclude=[ClientError],  # эти не считаются failures
)

@breaker
def fetch_data(url: str):
    response = requests.get(url, timeout=10)
    if 400 <= response.status_code < 500 and response.status_code != 429:
        # Client errors (кроме rate limit) -- не breaker fault
        raise ClientError(f"Status {response.status_code}")
    response.raise_for_status()
    return response.json()

Bulkhead pattern: изоляция

Bulkhead (от англ. «переборка корабля») — изоляция ресурсов так, чтобы failure одной части не утянула остальные.

В микросервисном контексте: ваш сервис ходит к 5 разным upstream API. Если все 5 используют общий thread pool / connection pool / общий circuit breaker — падение одного API может затронуть взаимодействие с другими.

Bulkhead -- изолированные пулы
Без bulkheadОдин общий пул на 100 connections. Upstream A зависает -- все 100 connections утекли в его таймауты. Запросы к B/C/D ждут
problem
Каскадный failureОдин медленный upstream блокирует весь сервис. Все запросы начинают таймаутиться, даже к рабочим API
С bulkheadКаждому upstream -- отдельный connection pool (например, по 25 connections). Зависание A тратит только его 25, B/C/D работают нормально
Изолированный failureТолько запросы к проблемному upstream страдают. Остальная функциональность работает

В Python с requests:

# Отдельные sessions для разных upstream -> отдельные connection pools
session_payments = requests.Session()
session_users = requests.Session()
session_inventory = requests.Session()

# Каждая session -- со своим breaker
breaker_payments = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=60)
breaker_users = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=60)
breaker_inventory = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=60)

@breaker_payments
def call_payments(path: str):
    return session_payments.get(f"https://payments.api.example.com{path}", timeout=10)

@breaker_users
def call_users(path: str):
    return session_users.get(f"https://users.api.example.com{path}", timeout=10)

Если payments API упал — breaker_payments в open, но breaker_users работает.


Fallback strategies: что делать при open

Когда breaker open или upstream недоступен — нужно как-то ответить пользователю. Несколько стандартных стратегий.

Fallback strategies
Cached responseИспользовать последний удачный ответ из кэша. Stale-while-revalidate: пока есть кэш -- не идём в upstream. Подходит, когда устаревшие данные приемлемы
Default valueВозвращаем пустой список / нулевое значение / placeholder. Подходит, когда отсутствие данных лучше, чем ошибка
Degraded modeОтдаём упрощённый ответ. Например, 'recommendations' fail -> возвращаем 'most popular items' из локальной БД
Queue / asyncНе отвечаем синхронно -- кладём задачу в очередь, обработаем позже. Подходит для асинхронных операций (отправить notification)
Fail-fastПросто вернуть 503 пользователю. Иногда честнее, чем фейковые данные. Хорошо для критичных операций -- лучше не сделать, чем сделать неправильно

Пример: cached fallback

import time
from typing import Optional
import pybreaker
import requests

breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=60)
_cache: dict[str, tuple[float, dict]] = {}

CACHE_FRESH_TTL = 60          # данные считаются «свежими» 60 секунд
CACHE_STALE_MAX = 3600        # не отдаём данные старше 1 часа


def get_user(user_id: int) -> Optional[dict]:
    cache_key = f"user:{user_id}"
    cached = _cache.get(cache_key)

    # Свежие -- отдаём без обращения к upstream
    if cached and time.time() - cached[0] < CACHE_FRESH_TTL:
        return cached[1]

    # Несвежие -- пытаемся обновить
    try:
        data = _fetch_user(user_id)
        _cache[cache_key] = (time.time(), data)
        return data
    except (pybreaker.CircuitBreakerError, requests.RequestException):
        # Upstream недоступен -- отдаём stale, если не слишком старое
        if cached and time.time() - cached[0] < CACHE_STALE_MAX:
            return cached[1]
        return None  # совсем нечего отдать


@breaker
def _fetch_user(user_id: int) -> dict:
    response = requests.get(
        f"https://api.example.com/users/{user_id}",
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

Stale-while-revalidate: если есть кэш, отдаём его, не блокируясь на upstream. Если upstream доступен — обновляем кэш в фоне.


Когда circuit breaker не подходит

Circuit breaker — мощный инструмент, но не silver bullet. Кейсы, когда он не помогает или мешает:

Limitations circuit breaker
Low trafficЕсли делаете 1 запрос в минуту, накопить 5 ошибок подряд занимает 5 минут -- за это время upstream уже мог восстановиться. Breaker реагирует слишком медленно
Critical operationsПлатёж пользователя -- нельзя 'fast fail' с fallback. Лучше попытаться много раз и упасть с ясной ошибкой, чем 'успешно' выполнить с deceptive fallback
Stateless dependenciesЕсли upstream stateless и быстро восстанавливается (миллисекунды), breaker не нужен -- обычный retry справляется
Один сервисВ монолите без upstream-сервисов breaker не имеет смысла -- нет 'удалённого' мёртвого ресурса

Главное правило: circuit breaker полезен, когда (1) есть удалённая зависимость, (2) трафик достаточен для статистики, (3) есть приемлемый fallback или fail-fast лучше, чем долгое ожидание.


Альтернативная реализация без библиотеки

Понимание устройства полезно, даже если используете готовое. Минимальная реализация:

import time
from enum import Enum
from typing import Callable, Any


class State(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 60,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = State.CLOSED
        self.failures = 0
        self.last_failure_time = 0

    def call(self, func: Callable, *args, **kwargs) -> Any:
        if self.state == State.OPEN:
            if time.time() - self.last_failure_time >= self.recovery_timeout:
                self.state = State.HALF_OPEN
            else:
                raise RuntimeError("Circuit breaker is OPEN")

        try:
            result = func(*args, **kwargs)
        except Exception:
            self.failures += 1
            self.last_failure_time = time.time()
            if self.failures >= self.failure_threshold:
                self.state = State.OPEN
            raise

        # Успех
        if self.state == State.HALF_OPEN:
            self.state = State.CLOSED
        self.failures = 0
        return result


# Использование
breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)

def fetch():
    return breaker.call(requests.get, "https://api.example.com/data", timeout=10)

Готово к усложнению: добавить sliding window для failure rate, success_threshold для half-open, error_classifier и т.д.


Combined pattern: rate limit + retry + circuit breaker

В production-grade ETL все три механизма работают вместе. Пример полного клиента:

import requests
import pybreaker
from pyrate_limiter import Duration, Rate, Limiter
from tenacity import (
    retry,
    stop_after_attempt,
    wait_random_exponential,
    retry_if_exception,
)


class ResilientAPIClient:
    def __init__(self, base_url: str, token: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {token}"

        # Layer 1: Proactive rate limit (никаких 429 в норме)
        self.limiter = Limiter(Rate(80, Duration.SECOND))

        # Layer 2: Circuit breaker (защита при upstream down)
        self.breaker = pybreaker.CircuitBreaker(
            fail_max=10,
            reset_timeout=60,
        )

    @staticmethod
    def _is_retryable(exc) -> bool:
        if isinstance(exc, requests.exceptions.RequestException):
            response = getattr(exc, "response", None)
            if response is None:
                return True  # network errors
            return response.status_code in (429, 502, 503, 504)
        return False

    @retry(
        retry=retry_if_exception(_is_retryable),
        stop=stop_after_attempt(3),
        wait=wait_random_exponential(multiplier=1, max=30),
        reraise=True,
    )
    def _make_request(self, method: str, path: str, **kwargs):
        # Layer 1
        self.limiter.try_acquire("default")
        # Layer 3: Reactive retry (внутри tenacity)
        # Layer 2: Circuit breaker оборачивает реальный HTTP
        return self.breaker.call(
            self.session.request,
            method,
            f"{self.base_url}{path}",
            timeout=30,
            **kwargs,
        )

    def get(self, path: str, **kwargs):
        response = self._make_request("GET", path, **kwargs)
        response.raise_for_status()
        return response.json()

Слои:

  1. Proactive rate limit (внешний) — pyrate-limiter. Не даём себе слать > 80 RPS.
  2. Circuit breakerpybreaker. Защищает от долгих outages: после 10 ошибок открываемся на минуту.
  3. Retry с backofftenacity. Краткосрочные всплески ошибок ретраим с full jitter.

Каждый слой решает свою задачу, вместе — production-ready resilience.


Что выбрать вместо breaker

Если breaker не подходит (low traffic, критичные операции), есть альтернативы:

  • Adaptive timeout: динамически меняем timeout в зависимости от истории. Если средний latency — 100 мс, ставим timeout 1 сек. Если 1 сек — ставим 10. Защита от долгих hangs без полного fail.
  • Hedged requests: при медленном ответе — посылаем дублирующий запрос на резервный endpoint. Берём первый успешный.
  • Load shedding на сервере: возвращать 503 рано, до перегрузки. Помогает breaker’у клиента сработать вовремя.

Эти техники глубже, чем нужно Junior DE, но знать о них полезно — встречаются в больших архитектурах.


Итоги урока

Circuit breaker — компонент с тремя состояниями: closed (норма), open (upstream мёртв, fast-fail), half-open (пробный запрос). Защищает от каскадных failures и долгих timeouts.

Параметры: failure_threshold (5 ошибок), recovery_timeout (60 сек), success_threshold (для half-open). Important: классифицировать ошибки — 4xx обычно не failures, 5xx и timeouts — да.

Bulkhead pattern — изоляция: отдельные connection pools / breakers для каждого upstream. Падение A не утягивает работу с B/C/D.

Fallback strategies: stale cache, default value, degraded mode, queue for later, fail-fast. Выбор зависит от контекста — где-то stale OK, где-то лучше честный 503.

В Python: pybreaker — стандартная библиотека. Combined с pyrate-limiter (proactive) и tenacity (retry) даёт production-grade resilience.

Где circuit breaker НЕ подходит: low-traffic сценарии (статистика накапливается медленно), critical operations (нельзя fast-fail), быстро восстанавливающиеся stateless dependencies.

Этим завершается модуль 9 — устойчивость к сбоям внешних сервисов. Pagination, rate limiting, retry, breaker — четыре навыка, отличающие Junior, который пишет «requests.get и поехали», от Junior, чей ETL переживает downtime партнёров без вашего вмешательства.


Проверка знанийKnowledge check
Junior рассуждает: 'У меня уже есть retry с exponential backoff и rate limiter -- зачем ещё circuit breaker? Это же дублирование защиты'. Что не так с этим выводом, и какую конкретную проблему решает только breaker?
ОтветAnswer
Каждый слой решает свою временную шкалу проблемы, и они НЕ заменяют друг друга. Rate limiter защищает от 'я слишком быстро шлю запросы' -- это про вашу скорость. Retry с backoff защищает от коротких throttle и временных всплесков ошибок (секунды, минуты) -- пытаетесь снова. Circuit breaker защищает от 'upstream мёртв' (часы, дни) -- перестаёт пытаться. Конкретная проблема, которую решает ТОЛЬКО breaker: каскадный failure при долгом outage upstream. Сценарий: API партнёра упал на час. Ваш ETL делает 100 RPS. Без breaker: каждый запрос идёт в upstream, висит 30 сек на таймауте, потом 5 retries по 30 сек каждый -- это 150 сек на ОДИН вызов. За час 100 RPS × 3600 сек = 360000 запросов, каждый висит 150 сек -- все потоки/connection pool забиты, основные операции тормозят. С breaker: после 10 ошибок открывается, следующие миллион запросов отвечают за миллисекунды CircuitBreakerError, освобождая ресурсы для других операций. Через минуту probe -- если upstream жив, восстанавливаемся; если нет -- снова open. Без breaker эту задачу не решить -- три слоя дополняют друг друга.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Какие три состояния у circuit breaker и в каком они меняются?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3