pybreaker
pybreaker implements the circuit breaker pattern from Michael Nygard's Release It!. You wrap a call that talks to something outside your process, such as a database, a queue or a third-party API, and the breaker counts consecutive failures. After fail_max failures it opens: every further call raises CircuitBreakerError immediately instead of waiting on a service you already know is down. After reset_timeout seconds it half-opens and lets one call through to test the water, closing again on success. It is one module of thread-safe pure Python, with optional Redis-backed state so several processes can share one breaker.
A clean, small implementation of a pattern most services eventually need, and the Redis storage is what lifts it above a weekend script. The gap that matters is asyncio: on an async codebase you are picking between an awkward fit and a different library.
Use it if
- A slow or dead dependency is dragging your whole service down because every request still waits for its timeout before failing
- You want failing fast to be a property of the integration point rather than something each call site remembers to implement
- You need to share breaker state across processes or machines, which the optional Redis storage covers
- You want hooks on state changes so you can log or alert the moment a dependency starts failing, via CircuitBreakerListener
- Your code is asyncio: the only async support here is for Tornado coroutines through call_async, there is no await-native path, and wrapping an async function with the decorator does not do what you expect
- The dependency fails intermittently rather than staying down: a breaker is the wrong shape for that and retries with backoff will serve you better
- You run on a service mesh or an API gateway that already does outlier ejection, since two layers of breaking makes failures much harder to reason about
- You deploy many worker processes and cannot add Redis, because the default in-memory storage means each worker trips independently and you get partial, confusing behaviour
- You want metrics out of the box: you get properties and listeners, and exporting them to Prometheus or anything else is code you write
Setup reality
pip install pybreaker is one small pure Python wheel with no required dependencies, and the API is small enough to learn in one sitting. The real work is placement. Breaker instances have to be module-level or otherwise long lived, because one created per request counts to five failures and then gets garbage collected, which quietly does nothing. Redis backing has two documented traps: initialise the client without decode_responses=True or state reads fail with an AttributeError about str objects, and give every breaker its own namespace or two independent integration points will share one circuit. Note also that the README states Python 3.10 or later while the package metadata still declares 3.9, so do not treat 3.9 as tested.
Patterns
Create one breaker per integration pointcreate-breaker
import pybreaker
db_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
name='customer-db',
)Module-level, not per request: a short-lived breaker never accumulates enough failures to open.
Guard a function with the decoratordecorator
@db_breaker
def update_customer(cust):
...
updated = update_customer(my_customer)The decorator only handles regular callables and Tornado coroutines; an async def function is not covered.
Wrap a call without decoratingcall-explicit
updated = db_breaker.call(update_customer, my_customer)The form to use when the function is someone else's, for example a client method you cannot decorate.
Guard a block of codecontext-manager
with db_breaker.calling():
conn.execute(sql)
conn.commit()Anything raising inside the block counts as one failure, so keep the block to a single integration point.
React when the circuit is openhandle-open
import pybreaker
try:
profile = fetch_profile(user_id)
except pybreaker.CircuitBreakerError:
profile = cached_profile(user_id)CircuitBreakerError is raised without calling the wrapped function at all, which is the whole point; this is where a fallback belongs.
Keep raising the underlying errororiginal-exception
db_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
throw_new_error_on_trip=False,
)Useful when existing error handling already understands the dependency's own exceptions and you do not want to teach it a new one.
Do not count business errors as failuresexclude-business-errors
db_breaker = pybreaker.CircuitBreaker(
exclude=[CustomerValidationError],
)
db_breaker.add_excluded_exception(DuplicateOrderError)Subclasses of an excluded type are excluded too; without this, a burst of user input errors will trip a perfectly healthy circuit.
Decide per exception instanceexclude-predicate
db_breaker = pybreaker.CircuitBreaker(
exclude=[lambda e: isinstance(e, HTTPError) and e.status_code < 500],
)Types and callables can be mixed in the same exclude list, which is how you keep 4xx responses from counting against an API.
Require several successes before closingsuccess-threshold
api_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
success_threshold=3,
)Stops a half-open circuit from slamming shut on one lucky response while the dependency is still recovering.
Share breaker state across processesredis-storage
import pybreaker
import redis
client = redis.StrictRedis() # not decode_responses=True
db_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
state_storage=pybreaker.CircuitRedisStorage(
pybreaker.STATE_CLOSED,
client,
namespace='customer-db',
),
)decode_responses=True fails with an AttributeError about str having no decode; the namespace is required once you have more than one breaker.
Log or alert on state changeslistener
import logging
import pybreaker
class LogListener(pybreaker.CircuitBreakerListener):
def state_change(self, cb, old_state, new_state):
logging.warning('breaker %s: %s', cb.name, new_state)
def failure(self, cb, exc):
logging.info('breaker %s failure: %r', cb.name, exc)
db_breaker.add_listeners(LogListener())Listeners run inline on the calling thread, so keep them cheap and never let one raise.
Inspect and drive the breaker at runtimemonitor-state
print(db_breaker.current_state) # 'open', 'half-open' or 'closed'
print(db_breaker.fail_counter)
print(db_breaker.success_counter)
db_breaker.close() # force closed
db_breaker.half_open() # let one call through
db_breaker.open() # force openExpose current_state on a health endpoint; forcing open is a useful manual switch during a known upstream incident.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| circuitbreaker | PyPI | You want a smaller decorator-first breaker and do not need Redis-backed shared state |
| aiobreaker | PyPI | Your calls are asyncio coroutines and you want a breaker built around await |
| tenacity | PyPI | The failures are transient, so retrying with backoff fits better than opening a circuit |
| stamina | PyPI | You want retries with sane defaults and jitter, sync or async, rather than breaker semantics |