Skip to content

Commit ad832f8

Browse files
committed
feat: add AsyncKeyedCircuitBreaker and KeyedCircuitBreaker
1 parent 5a69ce0 commit ad832f8

10 files changed

Lines changed: 705 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+
One independent circuit per **circuit key**, for a client whose requests go to more than one upstream. A plain `AsyncCircuitBreaker` holds a single circuit, so one failing upstream would fast-fail requests to every healthy one; the keyed breaker opens only the failing upstream's circuit.
265+
266+
Each circuit behaves exactly like an [`AsyncCircuitBreaker`](#asynccircuitbreaker-circuitbreaker) built with the same arguments: same states, failure classification, rate mode, half-open probe and events. The probe slot is per circuit, so two upstreams recovering at once each get their own probe.
267+
268+
### Constructor
269+
270+
Every `AsyncCircuitBreaker` parameter, with the same defaults, plus:
271+
272+
| Parameter | Default | Effect |
273+
|---|---|---|
274+
| `key` | `request.url.origin` | Maps a request to its circuit key. Any hashable value works. The default origin combines scheme, host and port, normalized, 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. |
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. Memory grows with the number of distinct keys, so `key` must map to a small, bounded set — configured upstreams, not a value taken 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 same events as `AsyncCircuitBreaker`, on the same `httpware.circuit_breaker` logger, each carrying one extra attribute: `circuit_key`, the `str()` of the request's circuit key.
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+
The keyed breaker takes the circuit breaker's place in the [composition](#composition): outside `AsyncRetry`, so it 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)