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 — это конечный автомат с тремя состояниями.
Полный цикл: Closed -> Open (накопили ошибки) -> Half-open (через timeout) -> Closed (если успех) или -> Open (если снова ошибка).
Sequence diagram: жизнь breaker
Покажем переходы через временную диаграмму.
Главное преимущество видно на шаге m7: вместо ожидания timeout (10-30 секунд) и получения ошибки от мёртвого upstream — мгновенный fail в миллисекундах. Клиент быстрее понимает «надо использовать fallback», не висит.
Метрики и пороги
Circuit breaker отслеживает метрики и переходит в open при превышении порогов. Ключевые параметры:
Грубо: «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 может затронуть взаимодействие с другими.
В 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 недоступен — нужно как-то ответить пользователю. Несколько стандартных стратегий.
Пример: 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. Кейсы, когда он не помогает или мешает:
Главное правило: 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()
Слои:
- Proactive rate limit (внешний) —
pyrate-limiter. Не даём себе слать > 80 RPS. - Circuit breaker —
pybreaker. Защищает от долгих outages: после 10 ошибок открываемся на минуту. - Retry с backoff —
tenacity. Краткосрочные всплески ошибок ретраим с 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 партнёров без вашего вмешательства.