python-circuit-breaker-policy
Use this when wrapping a Python SDK client around an external dependency that can go down, adding a backstop to a retry loop, designing fail-fast behavior for sustained downstream outage, or reviewing
SKILL.mdskills/python-circuit-breaker-policy/SKILL.md
GitHubpython-circuit-breaker-policy (v1.0.0)
Rationale
Retries amplify downstream pain. When the dependency is genuinely down, retrying makes things worse. A circuit breaker fails fast: after N consecutive failures, the breaker OPENS and rejects calls immediately for a cooldown window. After cooldown, ONE probe call decides whether to close (resume traffic) or stay open. The SDK gives consumers the breaker as a wrapper around the client method; the consumer chooses the thresholds.
This skill is cited by code-reviewer-python (resilience review-criteria), python-asyncio-patterns (cancellation safety), idempotent-retry-safety (retry pairs with breaker), python-otel-instrumentation (state-transition spans), and sdk-api-ergonomics-devil-python (E-4 exception design).
Activation signals
- SDK client wraps an external dependency that may go down.
- Production incident: client retried itself into amplification under outage.
- TPRD §3 declares a downstream availability concern.
- Reviewing retry logic — does it have a backstop?
Library choice
Three viable Python libraries (in preference order):
| Library | License | Async | Maintenance | Recommendation |
|---|---|---|---|---|
purgatory |
MIT | Yes (3.10+) | Active | DEFAULT for new code |
aiocircuitbreaker |
Apache-2.0 | Yes | Active | Acceptable if purgatory rejected |
pybreaker |
BSD-3 | Sync only (3.x) | Active | Fall back ONLY for sync code |
purgatory is the Python pack default: typed, async-native, simple decorator + context-manager API, tiny dependency footprint. Always vet before adoption per python-dependency-vetting.
Core states
CLOSED → every call passes through; failures counted
↓ (failure_threshold reached)
OPEN → every call REJECTED with OpenError; no downstream load
↓ (recovery_timeout elapsed)
HALF-OPEN → ONE probe call allowed; result decides next state
↓ probe success ↓ probe failure
CLOSED OPEN (reset cooldown)
The breaker is a state machine attached to a SCOPE (typically an endpoint or service). One breaker per scope — global breakers hide which dependency is failing.
Configuration policy
from purgatory import AsyncCircuitBreakerFactory
from purgatory.domain.model import CircuitBreaker
breaker_factory = AsyncCircuitBreakerFactory()
# Per-endpoint breaker
publish_breaker = await breaker_factory.get_breaker(
name="motadata.publish",
threshold=5, # failures before opening
ttl=30.0, # seconds in OPEN before half-open probe
)
Defaults (Python pack convention):
threshold=5— open after 5 consecutive failures.ttl=30s— recovery window. Tune per endpoint: 30s for fast services, 5–10 minutes for slow / batch.- ONE breaker per logical endpoint (
publish,fetch,delete). Don't pool across endpoints — different failure modes deserve different breakers.
Rule 1 — Decorator vs context manager
Decorator form (preferred for whole-method protection):
import logging
from purgatory import AsyncCircuitBreakerFactory
logger = logging.getLogger(__name__)
breaker_factory = AsyncCircuitBreakerFactory()
class Client:
async def publish(self, topic: str, payload: bytes) -> None:
async with await breaker_factory.get_breaker(
"motadata.publish", threshold=5, ttl=30.0
):
await self._http.post(self._url(topic), data=payload)
Context-manager form is the canonical pattern for purgatory — it scopes the protection to ONE await block. The breaker increments a failure count if the block raises; resets if it returns cleanly.
Rule 2 — Failure types — only network errors count
Not every exception is a circuit-breaker signal. A ValidationError is the caller's fault, not the downstream's. A breaker that opens on validation errors silently blocks legitimate traffic.
from motadatapysdk.errors import NetworkError, ServerError, TimeoutError as SDKTimeoutError
async def publish(self, topic: str, payload: bytes) -> None:
try:
async with await breaker_factory.get_breaker(...):
await self._http.post(...)
except (NetworkError, ServerError, SDKTimeoutError):
# These count as breaker failures
raise
except ValidationError:
# Caller error — NOT a breaker signal; re-raise without breaker reset
raise
Most circuit-breaker libraries (purgatory included) accept an excluded_exceptions= parameter to whitelist non-failure types:
breaker = await breaker_factory.get_breaker(
"motadata.publish",
threshold=5,
ttl=30.0,
excluded_exceptions=(ValidationError,), # don't count these as failures
)
Rule 3 — OpenError surfaces clearly
When the breaker is open, calls raise purgatory.OpenedState:
from purgatory.domain.model import OpenedState
from motadatapysdk.errors import CircuitOpenError
async def publish(self, topic: str, payload: bytes) -> None:
try:
async with await breaker_factory.get_breaker(...):
await self._http.post(...)
except OpenedState as e:
raise CircuitOpenError(
f"motadata.publish circuit is open; downstream is unavailable",
) from e
Wrap the library exception in the SDK's typed exception (CircuitOpenError) so consumers catch it via the MotadataError base. The library exception is an implementation detail.
CircuitOpenError is a subclass of MotadataError — define it in <pkg>/errors.py:
class CircuitOpenError(MotadataError):
"""The circuit breaker for an endpoint is open; downstream is unavailable."""
Rule 4 — Pair with retry — but bounded
import asyncio
import random
async def publish_with_retry(
self, topic: str, payload: bytes, *, max_retries: int = 3
) -> None:
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
async with await breaker_factory.get_breaker(
"motadata.publish", threshold=5, ttl=30.0
):
await self._http.post(self._url(topic), data=payload)
return
except CircuitOpenError:
# Breaker is open — STOP RETRYING. Bubble up so the consumer knows.
raise
except (NetworkError, SDKTimeoutError) as e:
last_exc = e
if attempt < max_retries:
delay = (2 ** attempt) * 0.1 + random.uniform(0, 0.05)
await asyncio.sleep(delay)
continue
raise
raise last_exc # mypy
Note the except CircuitOpenError: raise — when the circuit is open, retrying inside the same function is the failure mode the breaker is designed to prevent. Bubble up immediately.
For full retry policy (jitter, idempotency safety, retry-after), see idempotent-retry-safety (shared skill).
Rule 5 — Observe state transitions
Every breaker state change is operationally interesting. Hook them to OTel (per python-otel-instrumentation):
from opentelemetry import metrics, trace
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
breaker_state_changes = meter.create_counter(
"motadata.circuit_breaker.state_change",
description="Breaker state transitions",
)
# purgatory exposes a Hook protocol
class OTelHook:
async def on_state_change(self, breaker_name: str, old_state: str, new_state: str) -> None:
breaker_state_changes.add(
1, {"breaker": breaker_name, "from": old_state, "to": new_state},
)
with tracer.start_as_current_span("motadata.breaker.state_change") as span:
span.set_attribute("circuit_breaker.name", breaker_name)
span.set_attribute("circuit_breaker.from", old_state)
span.set_attribute("circuit_breaker.to", new_state)
Wire the hook into the factory:
breaker_factory = AsyncCircuitBreakerFactory(hooks=[OTelHook()])
Operators see the state transitions in their OTel backend; sustained OPEN states surface as alerts.
Rule 6 — Per-endpoint, NOT per-method-call
# WRONG — new breaker per call; state never accumulates
async def publish(self, topic: str, payload: bytes) -> None:
breaker = AsyncCircuitBreakerFactory().get_breaker(...) # leaks state
async with await breaker:
...
# RIGHT — breaker is instance-level (or factory-level)
class Client:
def __init__(self, config: Config) -> None:
self._breaker_factory = AsyncCircuitBreakerFactory()
async def publish(self, topic: str, payload: bytes) -> None:
async with await self._breaker_factory.get_breaker(
"motadata.publish", threshold=5, ttl=30.0
):
...
Factories are shared across calls. The factory caches breakers by name — repeated get_breaker("motadata.publish", ...) returns the SAME breaker. State accumulates.
Rule 7 — One breaker per logical endpoint
# WRONG — coarse-grained
breaker = factory.get_breaker("motadata", threshold=5, ttl=30.0)
# publish failures + fetch failures + delete failures → all reset the same counter
# RIGHT — per-method
publish_breaker = factory.get_breaker("motadata.publish", threshold=5, ttl=30.0)
fetch_breaker = factory.get_breaker("motadata.fetch", threshold=5, ttl=10.0)
Per-method breakers let one endpoint fail without blocking others. Different endpoints deserve different ttl (fetch is fast; recovery is fast — 10s; publish is slow — 30s).
Rule 8 — When NOT to use a circuit breaker
- Cache lookups — failure is cheap (cache miss); retry is cheap (re-fetch). Not worth the breaker overhead.
- One-shot operations in CLI tools — there's no sustained load to amplify.
- Idempotent-but-rare operations like a config refresh that runs once an hour — a breaker adds machinery for a single call.
- Non-network code — a CPU-bound hot path doesn't have a "downstream" to circuit.
The breaker pays off when the SDK is in a tight loop calling a remote endpoint. Most SDK clients fit; some don't.
Rule 9 — Configuration policy in Config
Expose the breaker thresholds as Config fields so consumers tune them:
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
base_url: str
api_key: str
breaker_failure_threshold: int = 5
breaker_recovery_seconds: float = 30.0
breaker_excluded_exceptions: tuple[type[Exception], ...] = (ValidationError,)
The Client uses these values at breaker construction:
async def publish(self, topic: str, payload: bytes) -> None:
breaker = await self._breaker_factory.get_breaker(
"motadata.publish",
threshold=self._config.breaker_failure_threshold,
ttl=self._config.breaker_recovery_seconds,
)
async with breaker:
...
Breaker breaker_failure_threshold = 0 should DISABLE the breaker (some consumers want it off for testing). Implement as:
if self._config.breaker_failure_threshold == 0:
await self._http.post(...)
else:
async with breaker:
await self._http.post(...)
GOOD: full client method with breaker + retry + OTel
import asyncio
import random
import logging
from opentelemetry import metrics, trace
from purgatory import AsyncCircuitBreakerFactory
from purgatory.domain.model import OpenedState
from motadatapysdk.errors import (
CircuitOpenError, MotadataError, NetworkError, ServerError, TimeoutError as SDKTimeoutError,
ValidationError,
)
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
publish_attempts = meter.create_counter(
"motadata.publish.attempts",
description="Publish attempt count by outcome",
)
class Client:
def __init__(self, config: Config) -> None:
self._config = config
self._breaker_factory = AsyncCircuitBreakerFactory()
async def publish(self, topic: str, payload: bytes) -> None:
"""Publish ``payload`` to ``topic``.
Raises:
ValidationError: If ``topic`` is empty.
CircuitOpenError: If the publish endpoint's circuit is open.
NetworkError: On wire failure after exhausting retries.
"""
if not topic:
raise ValidationError("topic must not be empty")
breaker = await self._breaker_factory.get_breaker(
"motadata.publish",
threshold=self._config.breaker_failure_threshold,
ttl=self._config.breaker_recovery_seconds,
excluded_exceptions=(ValidationError,),
)
max_retries = self._config.max_retries
for attempt in range(max_retries + 1):
try:
async with breaker, tracer.start_as_current_span("motadata.client.publish") as span:
span.set_attribute("messaging.destination.name", topic)
span.set_attribute("attempt", attempt)
await self._do_publish(topic, payload)
publish_attempts.add(1, {"outcome": "success"})
return
except OpenedState as e:
publish_attempts.add(1, {"outcome": "circuit_open"})
raise CircuitOpenError(
"motadata.publish circuit is open; downstream is unavailable"
) from e
except (NetworkError, ServerError, SDKTimeoutError):
publish_attempts.add(1, {"outcome": "retry"})
if attempt >= max_retries:
raise
delay = min(2 ** attempt, 30) * 0.1 + random.uniform(0, 0.05)
logger.info("publish retry %d after %.2fs", attempt + 1, delay)
await asyncio.sleep(delay)
Demonstrated: per-endpoint breaker (Rule 7), excluded ValidationError (Rule 2), CircuitOpenError wrap (Rule 3), retry pairs with breaker (Rule 4), OTel observation (Rule 5), Config-driven thresholds (Rule 9), bounded retries.
BAD anti-patterns
# 1. New breaker per call
async def publish(self, topic):
factory = AsyncCircuitBreakerFactory()
breaker = await factory.get_breaker("...") # state never accumulates
# 2. Coarse breaker scope
factory.get_breaker("everything", ...) # one failing endpoint blocks all
# 3. Counting validation errors as breaker failures
async with breaker:
if not topic:
raise ValidationError("...") # opens breaker for caller's bug
# 4. Retry loop that ignores OpenedState
for _ in range(retries):
try:
async with breaker: ...
except Exception: # OpenedState retried = amplification
await asyncio.sleep(1)
continue
# 5. Library exception leaks past public API
async def publish(self, topic, payload):
async with breaker: ... # OpenedState bubbles to caller
# caller now depends on purgatory's exception type
# 6. No OTel signal on state transition
# Operator can't see WHY traffic dropped to zero
# 7. Hardcoded thresholds
breaker = factory.get_breaker("...", threshold=5, ttl=30) # not Config-driven; consumer can't tune
# 8. Breaker on cache lookup
async def cache_get(key):
async with breaker: return self._cache[key] # cache miss is cheap; breaker overhead is wasted
# 9. Sync breaker (pybreaker) in async code
sync_breaker = pybreaker.CircuitBreaker(...)
async def f():
with sync_breaker: await something() # blocks event loop
# 10. excluded_exceptions=(Exception,)
# defeats the breaker entirely; everything is "excluded"
Cross-references
python-asyncio-patternsRule 4 (cancellation safety) —async with breakersurvives cancellation.python-exception-patterns—CircuitOpenErrorextendsMotadataError.idempotent-retry-safety— retry pairs with breaker; only retry idempotent operations.python-otel-instrumentation— state-transition observation.python-sdk-config-pattern— breaker thresholds are Config fields.python-dependency-vetting— vetpurgatory(or alternative) before adoption.network-error-classification— which exceptions count as breaker failures.