Skip to content

Commit d97ea96

Browse files
authored
feat: add AsyncKeyedCircuitBreaker and KeyedCircuitBreaker (#142)
* feat: add AsyncKeyedCircuitBreaker and KeyedCircuitBreaker * docs: warn that circuit_key is logged unredacted * docs: tighten the keyed circuit breaker section * fix: build the default circuit key without URL.origin, absent before httpx2 2.11 * style: wrap the keyed breaker docstring
1 parent 160acbd commit d97ea96

10 files changed

Lines changed: 726 additions & 48 deletions

‎CONTEXT.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ The circuit breaker's unit of account: a `NetworkError`, an httpware `TimeoutErr
5050
circuit state. "Failure" alone is ambiguous here: a failed request is very often not a counted
5151
failure.
5252

53+
**Circuit key**:
54+
The value a keyed circuit breaker computes from a request to pick its circuit; requests with equal
55+
keys share one circuit. The default is the URL's origin: scheme, host and port.
56+
_Avoid_: host — the host alone merges upstreams that differ only in scheme or port.
57+
5358
**Cap**:
5459
`max_response_body_bytes` — the bound on how many bytes httpware buffers on the caller's behalf.
5560
Counted *decoded*, status-agnostic, and never applied to user-driven `stream()` iteration.

‎docs/observability.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Logger names and event names are the stable public contract:
1313
| `httpware.circuit_breaker` | `circuit.opened` (WARNING), `circuit.rejected` (WARNING), `circuit.half_open` (INFO), `circuit.closed` (INFO) |
1414
| `httpware.timeout` | `timeout.exceeded` (WARNING) |
1515

16-
Each log record carries an `event` field with the event-name string (e.g. `event="circuit.opened"`), usable for log-aggregator filtering. See [resilience.md](resilience.md) for the full event tables per middleware.
16+
Each log record carries an `event` field with the event-name string (e.g. `event="circuit.opened"`), usable for log-aggregator filtering. Events from `AsyncKeyedCircuitBreaker` / `KeyedCircuitBreaker` also carry `circuit_key`, naming the circuit they belong to. See [resilience.md](resilience.md) for the full event tables per middleware.
1717

1818
```python
1919
import logging

‎docs/resilience.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ A key ordering constraint: `AsyncBulkhead` must sit outside `AsyncRetry` (before
1616
- [`RetryBudget`](#retrybudget)
1717
- [`AsyncBulkhead`](#asyncbulkhead)
1818
- [`AsyncCircuitBreaker` / `CircuitBreaker`](#asynccircuitbreaker-circuitbreaker)
19+
- [`AsyncKeyedCircuitBreaker` / `KeyedCircuitBreaker`](#asynckeyedcircuitbreaker-keyedcircuitbreaker)
1920
- [`AsyncTimeout`](#asynctimeout)
2021
- [Sync `Retry` and `Bulkhead`](#sync-retry-and-bulkhead)
2122

@@ -253,6 +254,49 @@ async with AsyncClient(
253254

254255
Sync usage is identical: `Client` + `CircuitBreaker`, no `await`.
255256

257+
## `AsyncKeyedCircuitBreaker` / `KeyedCircuitBreaker`
258+
259+
```python
260+
from httpware.middleware.resilience import AsyncKeyedCircuitBreaker # async
261+
from httpware.middleware.resilience import KeyedCircuitBreaker # sync
262+
```
263+
264+
Use a keyed breaker when one client sends requests to more than one upstream. A plain `AsyncCircuitBreaker` holds a single circuit, so one failing upstream fast-fails requests to all the healthy ones. The keyed breaker keeps a separate circuit for each circuit key and opens only the failing upstream's circuit.
265+
266+
Each circuit behaves exactly like an [`AsyncCircuitBreaker`](#asynccircuitbreaker-circuitbreaker) built with the same arguments: the same states, failure classification, rate mode, half-open probe and events. Each circuit has its own probe slot, so two upstreams that recover at the same time are probed independently.
267+
268+
### Constructor
269+
270+
Every `AsyncCircuitBreaker` parameter, with the same defaults, plus:
271+
272+
| Parameter | Default | Effect |
273+
|---|---|---|
274+
| `key` | the request's origin | Maps a request to its circuit key. Any hashable value works. The default is the origin as a string built from scheme, host and port, such as `https://a.example` or `http://a.example:8080`. Hosts are lowercased and default ports dropped, so `https://A.example/x` and `https://a.example:443/y` share a circuit, while `http://a.example` and `https://a.example:8443` each get their own. Userinfo is never part of it. |
275+
276+
### Circuit lifetime
277+
278+
A circuit is created on the first request for its key and kept for as long as the breaker lives. Nothing is evicted or pruned, so memory grows with the number of distinct keys. `key` must therefore map requests to a small, bounded set, such as your configured upstreams. Never derive it from user input. A key function that returns something unbounded, such as the full URL, grows the map forever.
279+
280+
### Observability
281+
282+
The keyed breakers emit the same events as `AsyncCircuitBreaker` on the same `httpware.circuit_breaker` logger. Each event has one extra attribute, `circuit_key`, which is the `str()` of the request's circuit key. httpware redacts `url` but not `circuit_key`, which reaches log records and span events exactly as the key function returned it. A custom `key` must not return credentials, tokens or other sensitive values. The default origin contains none.
283+
284+
### Example
285+
286+
```python
287+
from httpware import AsyncClient
288+
from httpware.middleware.resilience import AsyncKeyedCircuitBreaker, AsyncRetry
289+
290+
291+
async with AsyncClient(
292+
middleware=[AsyncKeyedCircuitBreaker(failure_threshold=5, reset_timeout=60.0), AsyncRetry()],
293+
) as client:
294+
await client.get("https://suggest-a.example/v1/suggest")
295+
await client.get("https://suggest-b.example/v1/suggest") # unaffected if suggest-a is down
296+
```
297+
298+
In the [composition](#composition), the keyed breaker goes where the circuit breaker goes. It sits outside `AsyncRetry` and counts one outcome per retry sequence. Sync usage is identical: `Client` + `KeyedCircuitBreaker`, no `await`.
299+
256300
## `AsyncTimeout`
257301

258302
```python

‎src/httpware/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@
4242
from httpware.middleware.resilience import (
4343
AsyncBulkhead,
4444
AsyncCircuitBreaker,
45+
AsyncKeyedCircuitBreaker,
4546
AsyncRetry,
4647
AsyncTimeout,
4748
Bulkhead,
4849
CircuitBreaker,
4950
CircuitState,
51+
KeyedCircuitBreaker,
5052
Retry,
5153
RetryBudget,
5254
)
@@ -57,6 +59,7 @@
5759
"AsyncBulkhead",
5860
"AsyncCircuitBreaker",
5961
"AsyncClient",
62+
"AsyncKeyedCircuitBreaker",
6063
"AsyncMiddleware",
6164
"AsyncNext",
6265
"AsyncRetry",
@@ -74,6 +77,7 @@
7477
"DecodeError",
7578
"ForbiddenError",
7679
"InternalServerError",
80+
"KeyedCircuitBreaker",
7781
"Middleware",
7882
"MissingDecoderError",
7983
"NetworkError",

‎src/httpware/middleware/resilience/__init__.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,27 @@
22

33
from httpware.middleware.resilience.budget import RetryBudget
44
from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
5-
from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker, CircuitState
5+
from httpware.middleware.resilience.circuit_breaker import (
6+
AsyncCircuitBreaker,
7+
AsyncKeyedCircuitBreaker,
8+
CircuitBreaker,
9+
CircuitState,
10+
KeyedCircuitBreaker,
11+
)
612
from httpware.middleware.resilience.retry import AsyncRetry, Retry
713
from httpware.middleware.resilience.timeout import AsyncTimeout
814

915

1016
__all__ = [
1117
"AsyncBulkhead",
1218
"AsyncCircuitBreaker",
19+
"AsyncKeyedCircuitBreaker",
1320
"AsyncRetry",
1421
"AsyncTimeout",
1522
"Bulkhead",
1623
"CircuitBreaker",
1724
"CircuitState",
25+
"KeyedCircuitBreaker",
1826
"Retry",
1927
"RetryBudget",
2028
]

0 commit comments

Comments
 (0)