Skip to content

feat: add AsyncKeyedCircuitBreaker and KeyedCircuitBreaker - #142

Merged
lesnik512 merged 5 commits into
mainfrom
feat/keyed-circuit-breaker
Sep 27, 2026
Merged

lesnik512 merged 5 commits into
mainfrom
feat/keyed-circuit-breaker

Conversation

@lesnik512

@lesnik512 lesnik512 commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

What

Adds AsyncKeyedCircuitBreaker and its sync mirror, KeyedCircuitBreaker. Both keep one independent circuit per circuit key. The default key is the request's origin as a string such as https://a.example, built from url.scheme and url.netloc: the host is lowercased, default ports are dropped, and userinfo is never included. URL.origin would be the obvious source, but it only exists from httpx2 2.11, and httpware supports httpx2>=2.0.0. Each circuit behaves like an AsyncCircuitBreaker built with the same arguments. Both classes are exported from httpware and httpware.middleware.resilience.

The admit, forward and record logic of AsyncCircuitBreaker.__call__ and CircuitBreaker.__call__ moves into the module-level functions _call_async and _call_sync, which the plain and keyed breakers now share. _CircuitBreakerState takes an optional key, which adds a circuit_key attribute to its events. Plain breakers pass no key, so their events are unchanged.

Why

When one client sends requests to several upstreams, for example an endpoint URL configured per tenant, a single AsyncCircuitBreaker does not work. One failing upstream opens the shared circuit, and requests to the healthy upstreams fast-fail too. Until now, callers had to hand-roll a wrapper holding a map of breakers.

Design decisions

  • The keyed breakers are separate classes rather than a key= option on AsyncCircuitBreaker. That way AsyncCircuitBreaker keeps its one-instance, one-circuit contract and its state property, which has no single meaning when there are many circuits.
  • The default key is the origin, not the host. A host-only key merges upstreams that differ only in scheme or port.
  • The map of circuits is unbounded, and entries are never evicted or pruned. LRU eviction could reset an OPEN circuit and send traffic straight back to a broken upstream. Pruning empty entries would need a per-entry in-flight count so a concurrent request's outcome is not dropped, and in rate mode a healthy entry never becomes empty anyway. The expected key sets are small, and the docs require the key to take a bounded set of values.
  • There is no API to read one key's state. Pruning and a per-key state API can both be added later without breaking changes.
  • circuit_key is not redacted, and the docs warn that a custom key must not contain secrets. The default origin contains none.
  • The sync mirror is included, per ADR-0002. KeyedCircuitBreaker holds one lock for circuit lookup and for every transition across all keys, because each transition is only a few attribute writes.
  • Like AsyncCircuitBreaker, the async class binds to one event loop. It has its own error message for cross-loop use.

Checks (local)

  • just lint: pass
  • just test-ci: 843 passed, coverage 100.00%
  • Full suite against the floor versions (httpx2 2.0.0, as the floors job resolves them) on 3.11: 843 passed
  • just docs-build: pass
  • pytest -m stress on 3.14t: 6 passed

@lesnik512
lesnik512 merged commit d97ea96 into main Sep 27, 2026
13 checks passed
@lesnik512
lesnik512 deleted the feat/keyed-circuit-breaker branch September 27, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant