API reference¶
Everything below is importable from the top-level interlock package, except
the integration adapters, which live in their own modules to keep the core
dependency-free.
CircuitBreaker¶
CircuitBreaker(*, name, config=None, clock=None, initial_state=State.CLOSED,
classifier=None, listener=None, storage=None)
A named breaker for sync and async callables.
- Use as a decorator (
@breaker), a sync/async context manager (with/async with), orbreaker.call(fn, *args, **kwargs). call_sync(fn, ...)/call_async(fn, ...)— the same protection with the sync/async dispatch skipped, for callers that already know their own nature (the transport integrations use them per request).call_syncnever awaits: a coroutine function passed to it is recorded as an immediate success.call_asyncawaits whateverfnreturns, so it accepts any awaitable-returning callable, not only a coroutine function.- Properties:
name: str,state: State. initial_state— one ofCLOSED,FORCED_OPEN,DISABLEDorMETRICS_ONLY; transitionalOPEN/HALF_OPENraiseValueError.snapshot() -> WindowSnapshot— current, self-consistent window aggregates; concurrent call settlement cannot expose a partially updated window.- Manual control:
reset(),force_open(),disable(),metrics_only(). close()/aclose()— release background resources: the coordinator lane and theauto_transitiontimer. Teardown, not a state change: neither closes the circuit (reset()does). Idempotent, and terminal — the lane never restarts. See Shutdown.storage— optional shared backend (StorageorAsyncStorage) for coordinated state across instances; see the Redis integration. A coordinated breaker matches its storage's runtime (sync storage → sync API, async storage → async API); without a storage the breaker stays fully dual.
Config¶
Frozen dataclass of thresholds, window and timing; validated on construction.
See Configuration for every field. Raises
ValueError on invalid input.
Registry¶
Registry(*, config=None, clock=None, initial_state=State.CLOSED,
classifier=None, listener=None, storage=None)
registry.get(name, *, config=None) -> CircuitBreaker
registry.get_existing(name) -> CircuitBreaker | None
registry.names() -> tuple[str, ...]
registry.items() -> tuple[tuple[str, CircuitBreaker], ...]
registry.close_all() / await registry.aclose_all()
Creates and caches named breakers. The same name always returns the same
instance; the per-call config override applies only at creation. A cache hit
is served without taking the registry lock, so a registry shared by every
request of a service does not serialise on lookups. A storage
is handed to every breaker the registry creates; each coordinates under its own
name. initial_state is assigned before a lazy breaker is published;
get_existing() inspects the cache without creating a missing name.
names() and items() enumerate the breakers created so far — the only way to
see the ones the HTTP transports create lazily, one per host. Both take a
point-in-time copy under the registry lock: a breaker created afterwards is not
in it, and the returned tuple never changes.
close_all() / aclose_all() close every breaker created so far. The cache is
kept, so get() keeps returning the same, torn-down instances instead of
silently starting a fresh lane after shutdown.
Enums¶
State—CLOSED,OPEN,HALF_OPEN,FORCED_OPEN,DISABLED,METRICS_ONLY. AStrEnum; values are stable lowercase identifiers.Outcome—SUCCESS,FAILURE,SLOW_SUCCESS,SLOW_FAILURE, with.is_failureand.is_slowproperties.WindowType—COUNT_BASED,TIME_BASED.
WindowSnapshot¶
Frozen dataclass: total_calls, failed_calls, slow_calls, plus
.failure_rate and .slow_call_rate properties (both 0.0 when empty).
Errors & warnings¶
InterlockError— base of all interlock errors.CircuitOpenError(breaker_name, *, retry_after=None, last_failure=None)— raised on rejection; attributesbreaker_name,retry_after,last_failure.CallTimeoutError(timeout)— raised bytimeoutandsync_timeout; attributetimeout.BulkheadFullError(max_concurrent, *, max_wait=0.0)— raised by a pipeline bulkhead when no concurrency slot frees up in time; attributesmax_concurrent,max_wait.InterlockDeprecationWarning— subclassesUserWarning, visible by default.
Every HTTP client integration retypes the rejection — and only the
rejection — as a subclass that is also a native error of the host library, so
the idiom that library teaches and except CircuitOpenError catch the same
error. The native base differs per integration; one except clause covers one
client, not all four:
| Integration | Rejection type | Native base |
|---|---|---|
| httpx | CircuitOpenTransportError |
httpx.TransportError |
| httpx2 | CircuitOpenTransportError |
httpx2.TransportError |
| aiohttp | CircuitOpenClientError |
aiohttp.ClientConnectionError |
| requests | CircuitOpenRequestError |
requests.exceptions.ConnectionError |
CallTimeoutError and BulkheadFullError are paired only by the httpx and
httpx2 transports, as CallTimeoutTransportError (a TimeoutException) and
BulkheadFullTransportError (a PoolTimeout); they surface a timeout or
bulkhead raised by a layer inside the wrapped transport. The aiohttp middleware
and the requests adapter leave both untouched — neither hierarchy has an honest
type for "no local slot was free" — so they reach the caller as plain interlock
errors. See the integration sections below.
timeout / sync_timeout¶
async with timeout(seconds):
... # async block
@sync_timeout(seconds) # synchronous callable
def work(): ...
timeout is an async context manager that raises CallTimeoutError if the
block exceeds seconds. sync_timeout is a decorator that runs a synchronous
callable in a daemon worker thread and raises CallTimeoutError if it overruns
seconds; the worker keeps running after a timeout (Python cannot kill a
thread). See Timeout.
Pipeline¶
Compose strategies around one call, outermost first — see the pipeline guide:
Pipeline(*strategies)— the executor; works as a signature-preserving decorator and aspipeline.call(fn, *args, **kwargs)(detect-dispatching, like the breaker's). No context manager by design.Pipeline.builder()/PipelineBuilder— step-by-step assembly:.fallback(...),.retry(...)(lazytenacityextra),.circuit_breaker(breaker),.bulkhead(...),.timeout(seconds),.add(custom),.build().Strategy— the structural protocol:execute(call)/execute_async(call);execute_asyncalways receives a real coroutine function.CircuitBreakerStrategy(breaker)— wraps a standalone breaker unchanged.TimeoutStrategy(seconds)— bounds every attempt via the v1 primitives.BulkheadStrategy(max_concurrent, *, max_wait=0.0, name='bulkhead', listener=None)— concurrency cap; raisesBulkheadFullError.FallbackStrategy(fallback, *, on=(Exception,), name='fallback', listener=None)— explicit substitution for selected failures; result typedT | F.RetryStrategy(...)— lives ininterlock.integrations.tenacity(see below).
Protocols (extension points)¶
Implement any of these to swap a core behaviour:
Clock—monotonic() -> float. Inject a fake for deterministic tests.SlidingWindow—record(outcome),snapshot() -> WindowSnapshot.Storage/AsyncStorage— shared-state backend as atomic intent operations:read,trip_open,begin_half_open_if_elapsed,lease_probe,record_probe,close.trip_open/closetake an optionalexpected_version(version-fenced CAS); every write carries attl. Mechanism only — threshold policy stays in the core.AsyncStorageis the awaitable mirror. See the Redis integration.FailureClassifier—is_failure(*, result, exception) -> bool, whereexceptionis anExceptionorNone— cancellation and shutdown are released without being classified. See Failure classification.EventListener—on_state_change,on_call,on_rejected,on_reset, pluson_storage_degraded/on_storage_recovered/on_storage_write_droppedfor coordinated breakers andon_retry/on_bulkhead_rejected/on_fallbackfor pipeline strategies (all optional hooks are dispatched only if present, so older listeners keep working). See Observability.
Shared-state types¶
SharedState— frozen snapshot of one breaker's coordinated state:state,opened_at(backend time),version(for fencing), and the HALF_OPEN probe accounting (probes_permitted,probes_remaining,probes_completed,probe_failures,probe_slows).SharedState.closed()is the baseline an absent key implies.ProbeLease— result oflease_probe:granted: boolplus the post-attemptstate: SharedState.
Listeners¶
LoggingEventListener(logger=None)— top-level; zero dependencies.interlock.integrations.otel.OTelEventListener(meter=None)— extrainterlock-cb[otel].
httpx2 adapters¶
Extra interlock-cb[httpx2], module interlock.integrations.httpx2:
CircuitBreakerTransport(transport, *, config=None, clock=None, initial_state=State.CLOSED, classifier=None, listener=None, registry=None, name_resolver=<request host>)AsyncCircuitBreakerTransport(transport, *, ...)HttpStatusClassifier(*, failure_statuses=None, excluded_exceptions=None)— fails on transport exceptions and statuses429, 500, 502, 503, 504(override the set viafailure_statuses).UnsupportedProtocolandLocalProtocolErrorare caller-side and count as successes; replace that set viaexcluded_exceptions.CircuitOpenTransportError(breaker_name, *, retry_after=None, last_failure=None, request=None)— the rejection: anhttpx2.TransportErrorand aCircuitOpenError.CallTimeoutTransportError(timeout, *, request=None)— anhttpx2.TimeoutExceptionand aCallTimeoutError.BulkheadFullTransportError(max_concurrent, *, max_wait=0.0, request=None)— anhttpx2.PoolTimeoutand aBulkheadFullError.
Both transports expose their per-host registry and the guarded transport as a
read-only wrapped. close() / aclose() release the wrapped transport and,
when the transport owns the registry, every breaker in it; a caller-owned
registry stays open and is closed by its owner. Every adapter below takes the
same registry option — a caller-owned Registry shared between clients — and
combining it with any breaker-construction option (config, clock,
initial_state, classifier, listener) raises ValueError, since the
registry already owns them. name_resolver maps each request to its breaker
name and defaults to the request host. See the
httpx2 integration.
httpx adapters¶
Extra interlock-cb[httpx] (httpx ≥ 0.27.0), module
interlock.integrations.httpx:
CircuitBreakerTransport(transport, *, config=None, clock=None, initial_state=State.CLOSED, classifier=None, listener=None, registry=None, name_resolver=<request host>)AsyncCircuitBreakerTransport(transport, *, ...)HttpStatusClassifier(*, failure_statuses=None, excluded_exceptions=None)— same policy, including the caller-sideUnsupportedProtocol/LocalProtocolErrorexclusions.CircuitOpenTransportError,CallTimeoutTransportError,BulkheadFullTransportError— the same three dialect errors as the httpx2 adapters, built onhttpx.TransportError,httpx.TimeoutExceptionandhttpx.PoolTimeout.
Both transports expose their per-host registry and the guarded transport as a
read-only wrapped, and preserve streaming responses. close() / aclose()
release the wrapped transport and, when the transport owns the registry, every
breaker in it; a caller-owned registry stays open and is closed by its owner.
See the httpx integration.
aiohttp adapters¶
Extra interlock-cb[aiohttp] (aiohttp ≥ 3.12), module interlock.integrations.aiohttp:
CircuitBreakerMiddleware(*, config=None, clock=None, initial_state=State.CLOSED, classifier=None, listener=None, registry=None, name_resolver=<request host>)— client middleware forClientSession(middlewares=(...,)); one breaker per request host.HttpStatusClassifier(*, failure_statuses=None, excluded_exceptions=None)— same canonical HTTP policy, readingClientResponse.status; nothing is excluded by default (aiohttp rejects bad URLs before the middleware runs).CircuitOpenClientError(breaker_name, *, retry_after=None, last_failure=None)— the rejection: anaiohttp.ClientConnectionErrorand aCircuitOpenError.
It exposes its registry; call await middleware.aclose() during application
shutdown. See the aiohttp integration.
requests adapters¶
Extra interlock-cb[requests], module interlock.integrations.requests:
CircuitBreakerAdapter(*, config=None, clock=None, initial_state=State.CLOSED, classifier=None, listener=None, registry=None, name_resolver=<request host>, **adapter_kwargs)—HTTPAdaptersubclass forsession.mount(...); one breaker per request host. Extra kwargs go toHTTPAdapter.HttpStatusClassifier(*, failure_statuses=None, excluded_exceptions=None)— same policy, readingResponse.status_code; the caller-sideInvalidURLcounts as a success.CircuitOpenRequestError(breaker_name, *, retry_after=None, last_failure=None, request=None)— the rejection: arequests.exceptions.ConnectionErrorand aCircuitOpenError; carries requests' own.requestand.response.
It exposes its registry; closing the adapter or owning session releases both
the connection pools and breaker resources. See the
requests integration.
tenacity helpers¶
Extra interlock-cb[tenacity], module interlock.integrations.tenacity:
retry_unless_open(*transient)— tenacity retry predicate: retries the listed transient exception types (default: anyException), neverCircuitOpenError.wait_probe(fallback, *, jitter=0.1)— tenacity wait strategy: sleepsCircuitOpenError.retry_after(+ up tojitterseconds) after a rejection, delegates tofallbackotherwise.RetryStrategy(*, attempts=3, retry=None, wait=None, sleep=None, async_sleep=None, before_sleep=None, name='retry', listener=None)— a bounded retry layer for the pipeline: policy delegated to tenacity, attempts always capped, the original exception re-raised when the budget runs out,CircuitOpenErrornot retried by default.
See the tenacity integration and the retries guide.
FastAPI adapters¶
Extra interlock-cb[fastapi], module interlock.integrations.fastapi:
breaker_dependency(name, *, registry)— returns aDepends-compatible callable yielding the named breaker from a sharedRegistry.install_exception_handler(app)— registers a handler mappingCircuitOpenErrorto503with aRetry-Afterheader.circuit_open_handler(request, exc)— the handler itself, for custom registration.
See the FastAPI integration.
Litestar adapters¶
Extra interlock-cb[litestar] (Litestar ≥ 2.23), module
interlock.integrations.litestar:
breaker_dependency(name, *, registry)— returns aProvideyielding the named breaker from a sharedRegistry; annotate handler parameters withNamedDependency[CircuitBreaker].circuit_open_handler(request, exc)— mapsCircuitOpenErrorto503with aRetry-Afterheader; pass it in the app'sexception_handlers.
See the Litestar integration.
Redis adapters¶
Extra interlock-cb[redis], module interlock.integrations.redis:
RedisStorage(client, *, key_prefix='interlock:cb:', state_ttl=300.0, poll_interval=1.0, retry_backoff=5.0, retry_backoff_multiplier=1.0, retry_backoff_max=None, retry_jitter=0.0, write_queue_size=128)— syncStorageover aredis.Redisclient.AsyncRedisStorage(client, *, ...)— async mirror overredis.asyncio.Redis.
One Redis hash per breaker; every transition is a Lua script (atomic across
racing instances), elapse checks use the server's TIME. Works against Redis
(5.0+), Valkey, or any RESP-compatible server.
See the Redis integration.