keel-circuit-breaker
Stop hammering a failing dependency. A circuit breaker keyed by any string: after N failures it skips a down target during a cooldown, then probes for recovery — so one flaky service (an LLM provider, an API, a DB) doesn't drag the rest down. Zero dependencies, async-friendly, observable.
Part of the Keel toolkit — composable, vendor-neutral libraries. Extracted from 1.5+ years of production use across 5 distinct LLM provider adapters; useful for any flaky external call.
Why it exists
Every product that calls a flaky external service (LLM providers, third-party APIs, internal microservices) re-implements the same pattern: after N consecutive failures, stop hammering the failing target for a while; after a cooldown, let a request through to see if it recovered. keel-circuit-breaker is that pattern, battle-tested and keyed by any string — so one instance serves per-model, per-tenant, per-endpoint, or per-anything use cases.
Is this for you?
Adopt when — any flaky external call (HTTP, DB, queue, LLM) where you want a small, zero-dep, keyed-by-string breaker; you like the caller-signals-failure + permissive half-open posture; you want one cohesive piece of the Keel toolkit.
Skip when — you already use pybreaker / circuitbreaker / aiobreaker and they work. The space is mature; this is a clean alternative, not category-defining. Pick on preference.
Install
pip install keel-circuit-breaker # or: uv add keel-circuit-breaker
Zero runtime dependencies (stdlib only).
Three worked examples
1. Guard an HTTP/LLM call (manual lifecycle)
from keel_circuit_breaker import CircuitBreaker
breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=120.0)
async def call_model(model_key: str, prompt: str) -> str:
if not breaker.is_available(model_key):
raise RuntimeError(f"{model_key} circuit open — skip it")
try:
result = await some_async_http_call(prompt)
breaker.record_success(model_key)
return result
except Exception:
breaker.record_failure(model_key) # YOU decide this is a failure
raise
2. The call() convenience wrapper
from keel_circuit_breaker import CircuitBreaker, CircuitOpenError
breaker = CircuitBreaker()
try:
result = await breaker.call(some_async_http_call, key="model-x", prompt="hi")
except CircuitOpenError as e:
... # e.key tells you which key is open; fall back to another target
3. Multi-tenant rate-isolation (keyed by tenant, not model)
breaker = CircuitBreaker(failure_threshold=5, cooldown_seconds=60.0)
# The key is any string — here, a tenant id. One tenant tripping the breaker
# does not affect another tenant's availability.
if breaker.is_available(tenant_id):
...
API
CircuitBreaker(
failure_threshold: int = 3, # consecutive failures before opening
cooldown_seconds: float = 120.0, # time open before probing (half-open)
logger: StructuredLogger | None = None, # default: logging.getLogger("keel_circuit_breaker")
)
breaker.is_available(key) -> bool # should this target be called?
breaker.record_success(key) -> None # fully resets the failure count
breaker.record_failure(key) -> None # increments; opens at threshold
breaker.get_status(key) -> "closed" | "open" | "half_open"
await breaker.call(fn, key, *args, **kwargs) # convenience wrapper; raises CircuitOpenError
StructuredLogger is a small protocol — info(event, **fields) / warning(event, **fields). structlog's BoundLogger satisfies it directly (the original production logger). The default is a stdlib-logging adapter that routes fields into extra=.
Design notes (read before changing behavior)
These are deliberate, load-bearing decisions carried over from production. They look like small choices; they aren't.
-
Monotonic clock, never wall-clock. Cooldowns use
time.monotonic(). Wall-clock time (time.time()) can move backward under NTP adjustments, DST transitions, or an operator runningdate— which would re-open a circuit prematurely or stretch a cooldown into next week. Don't "simplify" totime.time(). -
Permissive half-open. Once the cooldown elapses, all concurrent
is_available()callers seeTrueuntil one callsrecord_success. This is not classic single-shot half-open (which lets exactly one probe through). It's intentional: when the caller bounds concurrency by other means (e.g., per-key rate limiting), a small burst of probes gives faster recovery. If you need single-shot half-open, wrap it or open an issue — a futureprobe_concurrencyoption may add it as a non-breaking choice. -
Single-event-loop only. State is a plain dict, not lock-guarded. Designed for one event loop per process (the common async deployment). Concurrent mutation from multiple threads or event loops in the same process will corrupt state. Multi-thread safety is out of scope for
0.x. -
Success fully resets the failure count (no decay). One success forgives all accumulated failures, even at threshold-minus-one. Intentional: pick a healthy target back up immediately. A "decay over time" model would keep skipping a recovered target after transient errors.
-
The state dict is unbounded by key. Not a leak when the key space is bounded and application-controlled (a fixed set of models/tenants). If you pass user-supplied keys, this is a memory-growth / DoS vector — bound the key space yourself. LRU eviction may arrive at
1.0if a real consumer needs it. -
The caller decides what counts as a failure. The breaker never inspects responses or status codes. You call
record_failure()explicitly. This is deliberate: an HTTP 429 (rate-limited) usually should not open the circuit; a 500, a timeout, or malformed output usually should. That policy varies per provider and belongs to you, not the breaker. Don't wrap this in "smart" auto-detection. -
The defaults are tuned, not arbitrary.
failure_threshold=3skips flaky targets quickly while absorbing 1–2 transient errors.cooldown_seconds=120.0matches typical provider rate-limit windows. They're calibrated for free-tier LLM provider behavior — override them for your own SLAs. -
Log field names are a contract. Each state transition emits a structured record with both legacy field names (
model_key,failures,cooldown_seconds; eventscircuit_opened/circuit_closed/circuit_half_open) and canonical namespaced fields (keel.lib.name,keel.primitive,keel.event,keel.key,keel.failure_count,keel.cooldown_seconds). Dual emission is preserved throughout the entire0.xlifecycle; the legacy aliases are dropped at1.0.0. Update any log-based dashboards to thekeel.*fields before then.
Status
0.1.2 — 0.x while the API stabilizes through year one (breaking changes possible at minor bumps, documented in the CHANGELOG; pin exact versions).
The Keel toolkit
Composable, vendor-neutral LLM reliability libraries on PyPI:
keel-llm-reliability · keel-llm-protocol · keel-llm-adapter-openai · keel-llm-adapter-anthropic · keel-llm-adapter-google · keel-circuit-breaker
MIT licensed.
Metadata
Release files for keel-circuit-breaker 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| keel_circuit_breaker-0.1.2.tar.gz | 10.0 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| keel_circuit_breaker-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 17.9 kB
Release files / keel_circuit_breaker-0.1.2.tar.gz
| Download URL | keel_circuit_breaker-0.1.2.tar.gz |
|---|---|
| Size | 10.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4dc9764c5c42cbe90ac28a35138b05e52615692efa76b56f7a3c841f456edff4
|
|
BLAKE2b-256 checksum How to use checksums |
137ae8714ae39169d89dda43807a64a563cd349d2ae9d6b1e1dfc43ba199ed19
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 23, 2026.
Transparency logRelease files / keel_circuit_breaker-0.1.2-py3-none-any.whl
| Download URL | keel_circuit_breaker-0.1.2-py3-none-any.whl |
|---|---|
| Size | 7.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d4a45a8f00367054b6abc7ec3f55d02fae0d833b7d4fb39324a6ecd50e4d101a
|
|
BLAKE2b-256 checksum How to use checksums |
99f785efa6c4c2d0ba486179b2d6910c27de91f9f20d6305731cd25644f0ded0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 23, 2026.
Transparency logSecurity for keel-circuit-breaker 0.1.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for keel_circuit_breaker-0.1.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.