Using Circuit Breaker Libraries with asyncio¶
Before writing a circuit breaker, it is worth knowing what the existing libraries do under asyncio — because they differ in exactly the places that matter during an incident. Measured on Python 3.14 with aiobreaker 1.2.0, circuitbreaker 2.1.3, purgatory 3.0.1 and pybreaker 1.4.1, each set to open after 5 failures and probe again after 1 second, with 50 concurrent clients calling a dependency that failed for 2 seconds: aiobreaker, circuitbreaker and purgatory all opened, rejected 9,388–9,700 calls while open, and served again 41–42 ms after the dependency recovered. But when each went half-open, all 50 waiting clients reached the failing dependency at once, not a single probe. pybreaker's decorator on an async def never opened at all: 4,850 calls hit the failing dependency, because the decorator returned the coroutine before it ran. And purgatory opened after ten CancelledErrors — so cancelled requests count as dependency failures — where aiobreaker and circuitbreaker stayed closed. Overhead per call was 0.35–0.63 µs. This guide shows how to use the libraries and compensate for their gaps.
Prerequisites¶
- One of the libraries:
pip install aiobreaker,circuitbreakerorpurgatory. - How breakers work, from implementing an async circuit breaker.
- The topic overview, Circuit Breakers & Bulkheads.
1. Wire each library to an async call¶
The three asyncio-capable libraries wrap a call in different ways:
# aiobreaker: wrap the coroutine function
breaker = aiobreaker.CircuitBreaker(fail_max=5, timeout_duration=timedelta(seconds=1))
result = await breaker.call_async(fetch_quote, symbol)
# circuitbreaker: a decorator that understands async def
@circuitbreaker.circuit(failure_threshold=5, recovery_timeout=1, expected_exception=ConnectionError)
async def fetch_quote(symbol): ...
# purgatory: an async context manager per named breaker
breakers = purgatory.AsyncCircuitBreakerFactory(default_threshold=5, default_ttl=1)
async with await breakers.get_breaker("quotes"):
result = await fetch_quote(symbol)
Measured with a dependency that failed for 2 seconds under 50 concurrent clients: all three opened after the first failures, rejected 9,646, 9,700 and 9,479 calls respectively while open, and the first successful call came 41–42 ms after the dependency recovered. pybreaker also has a decorator, but applied to an async def it wraps only the creation of the coroutine; the exception is raised later, when the caller awaits it, outside the breaker. Measured: 4,850 calls reached the failing dependency and the breaker never rejected one. Its call_async targets Tornado rather than asyncio.
Verify: a test that makes the dependency fail sees the breaker reject calls; a breaker that never rejects is not wired to the failures.
2. Limit probes in the half-open state¶
A breaker in the half-open state should let a small number of trial calls through and keep rejecting the rest until a trial succeeds. Measured: every unhealthy call that reached the dependency did so at about 1.5 s — the moment each breaker went half-open — and there were 50 of them, one per waiting client, for all three libraries. Against a dependency that is just recovering, a burst of every queued caller at once is what can push it back over. Put a gate in front of the probe:
probe_gate = asyncio.Semaphore(1)
async def guarded_call(*args):
if breaker_is_half_open(): # library-specific state check
if probe_gate.locked():
raise BreakerOpen("probe in progress")
async with probe_gate:
return await breaker.call_async(fetch_quote, *args)
return await breaker.call_async(fetch_quote, *args)
aiobreaker exposes breaker.current_state; circuitbreaker exposes .state on the breaker returned by CircuitBreakerMonitor.get(name); purgatory's breakers have .context.state. Alternatively, bound concurrency to the dependency at all times with a bulkhead, so even a half-open burst is limited, as in bulkhead isolation with per-dependency semaphores.
Verify: when the breaker goes half-open with many callers waiting, at most the intended number of calls reach the dependency.
3. Decide which exceptions count¶
A breaker should count failures of the dependency, not failures of the caller. Measured by raising each exception ten times through each library with a threshold of five: TimeoutError opened all three — usually right, since a timing-out dependency is unhealthy. CancelledError left aiobreaker and circuitbreaker closed but opened purgatory. In a web service, requests are cancelled when clients disconnect or deadlines expire upstream; with purgatory, a burst of client disconnects can open the breaker against a healthy dependency. Exclude cancellation explicitly:
breakers = purgatory.AsyncCircuitBreakerFactory(
default_threshold=5, default_ttl=30,
exclude=[asyncio.CancelledError, ValueError], # caller-side outcomes, not dependency failures
)
@circuitbreaker.circuit(failure_threshold=5, recovery_timeout=30,
expected_exception=(ConnectionError, TimeoutError)) # count only these
async def fetch_quote(symbol): ...
Count connection errors, timeouts and server errors; do not count validation errors from bad input, 404s for missing records, or cancellations.
Verify: a test that raises each exception type the call can produce shows which ones move the breaker, and cancellation is not among them.
4. Keep one breaker per dependency, shared¶
A breaker only works if every call to the dependency goes through the same instance. Create breakers at start-up, keyed by dependency, and share them:
class Breakers:
def __init__(self):
self._by_name: dict[str, aiobreaker.CircuitBreaker] = {}
def get(self, name: str) -> aiobreaker.CircuitBreaker:
if name not in self._by_name:
self._by_name[name] = aiobreaker.CircuitBreaker(
fail_max=5, timeout_duration=timedelta(seconds=30), name=name)
return self._by_name[name]
breakers = Breakers()
quote = await breakers.get("quotes-api").call_async(fetch_quote, symbol)
A breaker created per request never accumulates failures and never opens. With several processes, each has its own breaker state; purgatory and aiobreaker can store state in Redis to share it, at the cost of a round trip per call, as discussed in sharing circuit breaker state across processes. Granularity — one breaker per host, per endpoint, per tenant — is a separate decision, covered in per-endpoint circuit breakers.
Verify: breakers are constructed once per dependency at start-up, and their state is visible in metrics.
5. Choose a library, or write your own¶
At 0.35–0.63 µs per call, overhead does not decide the choice. Behaviour does. A selection checklist from the measurements:
REQUIREMENTS = {
"awaits the call inside the breaker": True, # pybreaker's decorator fails this
"excludes CancelledError": True, # purgatory needs `exclude`
"limits half-open probes": True, # none of the three did; add a gate
"shared state across processes": "optional", # purgatory, aiobreaker via storage
"exposes state for metrics": True,
}
circuitbreaker had the smallest API and lowest overhead; aiobreaker and purgatory offer listeners and shared storage. All three needed help with half-open probing. If a library needs that much adaptation, a small breaker of your own may be simpler to reason about, and easier to test — see testing circuit breakers.
Verify: the chosen library passes tests for each item on the checklist before it goes in front of a production dependency.
Verification¶
A circuit breaker library is used correctly when:
- The breaker awaits the call, and a failing dependency makes it reject calls.
- Half-open probes are limited, by a gate or a bulkhead.
- Only dependency failures count, with
CancelledErrorexcluded. - One shared breaker exists per dependency, created at start-up.
Diagnostic Hook: when a breaker never opens during an outage, check whether it wraps an async def with a synchronous decorator. pybreaker's decorator let 4,850 calls reach a failing dependency without one rejection here, because the failures happened after the decorated function returned its coroutine.
Pitfalls & edge cases¶
- Synchronous breaker decorators on coroutines. Measured: the breaker never opened.
- Unlimited half-open probes. Measured: 50 of 50 waiting calls went through.
- Counting cancellations. purgatory opened after ten
CancelledErrors. - A breaker per request. It never accumulates enough failures to open.
Frequently Asked Questions¶
Which Python circuit breaker library works with asyncio?
aiobreaker, circuitbreaker (on async def) and purgatory all opened and recovered correctly here. pybreaker's decorator did not work on async functions.
Why did my circuit breaker let a burst through after the reset timeout?
All three libraries let every waiting caller through when half-open: 50 of 50. Gate the probe with an asyncio.Semaphore(1) or bound concurrency with a bulkhead.
Should a circuit breaker count CancelledError?
No. It reflects the caller giving up, not the dependency failing. purgatory counted it and opened after ten; exclude it explicitly.
How much overhead does a circuit breaker add?
0.35 to 0.63 µs per call in the closed state across circuitbreaker, aiobreaker and purgatory, negligible next to any network call.
Related¶
- Circuit Breakers & Bulkheads — up to the topic overview.
- Testing circuit breakers — checking these behaviours without waiting for real timeouts.
- Resilience, Cancellation & Error Handling — the section overview.