You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7b41094
Browse filesBrowse the repository at this point in the historyBrowse files
docs: put the circuit breaker before AsyncTimeout; document rate-mode and event-loop behaviour (#178)
* fix: explain per-event-loop construction in the async resilience cross-loop error
* docs: recommend the circuit breaker before AsyncTimeout so timeouts count as failures
* docs: explain that a success can open the circuit in rate mode
* docs: describe the one-event-loop-per-instance rule for async resilience middleware
* docs: align the full-stack demo with the breaker-before-timeout order
body:'The bulkhead caps concurrency so the slow phase cannot exhaust the client, and the timeout ends each operation after 2s.' },
36
-
{ when: (s) =>s.now>=5.4, spot: ['ifB'], title:'Phase 2: brownout',
34
+
{ when: (s) =>s.now>=2.4, spot: ['ifB'], title:'Phase 1: brownout',
37
35
body:'Retry recovers many of the transient errors, and the budget stops it from adding much load.' },
38
-
{ when: (s) =>s.mw.cb&&s.mw.cb.state==='OPEN', spot: ['brkB'], title:'Phase 3: hard down',
39
-
body:'Repeated failures open the breaker. It sits before the retry layer, so it rejects whole retry sequences and counts one outcome per sequence, not per attempt.' },
body:'The bulkhead caps concurrency so the slow upstream cannot exhaust the client, and the timeout ends each operation after 2s.' },
38
+
{ when: (s) =>s.mw.cb&&s.mw.cb.state==='OPEN', spot: ['brkB'], title:'Timeouts open the breaker',
39
+
body:'The breaker sits before the timeout, so each expired deadline counts as a failure. After five in a row the circuit opens, and new calls fail at once instead of waiting 2s on the slow upstream.' },
40
+
{ when: (s) =>s.now>=8.5, spot: ['brkB'], title:'Phase 3: hard down',
41
+
body:'The circuit stays open through the outage, because each half-open probe fails and reopens it. It sits before the retry layer, so it rejects whole retry sequences and counts one outcome per sequence, not per attempt.' },
40
42
{ when: (s) =>s.now>=13.5, spot: ['ifA', 'latA', 'ifB', 'latB'], title:'Full stack vs none',
41
43
body:'The plain client fails through every phase. In the httpware client, each layer handles the phase it is built for.' },
Copy file name to clipboardExpand all lines: docs/resilience.md
+36-5Lines changed: 36 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -182,6 +182,8 @@ An outcome only counts against the state its request was admitted under. A reque
182
182
183
183
A counted failure is a `NetworkError`, an httpware `TimeoutError`, or a `StatusError` whose status is in `failure_status_codes`. Other exceptions pass through without changing the circuit.
184
184
185
+
An expired `AsyncTimeout` deadline counts only when the breaker comes before `AsyncTimeout`, as in the [composition order](#composition). With the breaker after it, the deadline cancels the breaker's call, and a cancellation is not a counted failure.
186
+
185
187
A `RetryBudgetExhaustedError` from a retry placed after the breaker is classified by its `last_exception`. If the last attempt failed with a counted failure, the breaker counts one; if it failed with a 4xx, that is a success.
186
188
187
189
4xx responses, 429 included, count as successes. A 429 means the service is up and throttling you; opening the circuit on it would add circuit-open rejections on top of the throttling.
Once the window holds at least `minimum_calls` outcomes, the rate is checked after every outcome, successes included. A success can therefore be the call that opens the circuit. With `minimum_calls=4` and a threshold of `0.5`, three failures leave the circuit closed because the window holds only three outcomes; a fourth call that succeeds brings it to four, the rate is 3/4, and the circuit opens. That call still returns its response to the caller, and the next request is rejected with `CircuitOpenError`.
228
+
225
229
In rate mode `failure_threshold` is ignored. Recovery through HALF_OPEN works the same in both modes, and the sync `CircuitBreaker` takes the same parameters.
226
230
227
231
### State introspection
@@ -311,7 +315,7 @@ In the [composition order](#composition), the keyed breaker takes the circuit br
311
315
from httpware.middleware.resilience import AsyncTimeout
312
316
```
313
317
314
-
Bounds the total time of everything after it in the chain. Put it first so a call, with all its retries and backoff, finishes within `timeout` seconds. When time runs out it raises `httpware.TimeoutError`.
318
+
Bounds the total time of everything after it in the chain. Put it right after the circuit breaker and before `AsyncBulkhead` and `AsyncRetry`, so a call, with all its retries and backoff, finishes within `timeout` seconds and the breaker counts an expired deadline as a failure. When time runs out it raises `httpware.TimeoutError`.
315
319
316
320
| Parameter | Default | Effect |
317
321
|---|---|---|
@@ -321,17 +325,18 @@ Bounds the total time of everything after it in the chain. Put it first so a cal
321
325
322
326
There is no sync `Timeout`, because sync Python cannot interrupt a blocking httpx2 call midway. In sync code, set `httpx2.Timeout` on the client or pass `timeout=` per request.
323
327
324
-
On expiry it emits `timeout.exceeded` on `httpware.timeout`. [Composition](#composition) has an example with `AsyncTimeout`first in the chain.
328
+
On expiry it emits `timeout.exceeded` on `httpware.timeout`. [Composition](#composition) has an example with `AsyncTimeout` in the recommended place.
325
329
326
330
## Composition
327
331
328
332
httpware doesn't enforce an order, but this one is recommended:
-`AsyncTimeout` comes first, so the deadline covers every retry and backoff.
338
+
-`AsyncCircuitBreaker` comes before `AsyncTimeout`, so an expired deadline reaches the breaker as an httpware `TimeoutError`, which it counts as a failure, and an upstream that hangs opens the circuit. In the reverse order the deadline fires while the breaker is still waiting on its inner call: the breaker sees a `CancelledError`, which it doesn't count, and the circuit stays closed however long the upstream hangs. The breaker itself never waits, so the deadline loses nothing by starting after it. An open circuit still rejects at once, and the half-open probe runs under the deadline like any other call.
339
+
-`AsyncTimeout` comes before `AsyncBulkhead` and `AsyncRetry`, so the deadline covers the wait for a slot, every retry and every backoff.
335
340
-`AsyncCircuitBreaker` comes before `AsyncRetry`, so an open circuit rejects the call without starting the retry loop, and the breaker counts one outcome per retry sequence instead of one per attempt. Putting it before `AsyncBulkhead` too means a rejected call never takes a slot.
336
341
-`AsyncBulkhead` comes before `AsyncRetry`, so one slot covers every attempt of a call. In the reverse order each retry takes a new slot, and under load the bulkhead admits more work than its cap intends.
337
342
@@ -349,8 +354,8 @@ async def main() -> None:
349
354
asyncwith AsyncClient(
350
355
base_url="https://api.example.com",
351
356
middleware=[
352
-
AsyncTimeout(timeout=30.0),
353
357
AsyncCircuitBreaker(),
358
+
AsyncTimeout(timeout=30.0),
354
359
AsyncBulkhead(max_concurrent=10),
355
360
AsyncRetry(),
356
361
],
@@ -360,6 +365,32 @@ async def main() -> None:
360
365
361
366
Your own middleware that sets something per call, like the request-ID middleware in [Middleware](middleware.md), also belongs before `AsyncRetry`, so every attempt of a call shares one ID.
362
367
368
+
## One event loop per instance
369
+
370
+
`AsyncBulkhead`, `AsyncCircuitBreaker` and `AsyncKeyedCircuitBreaker` are bound to the event loop they first run on. Using the same instance from another loop raises `RuntimeError`, and so does a later `asyncio.run()` in the same thread, because each `asyncio.run()` starts a new loop. `httpx2.AsyncClient` can't be reused across `asyncio.run()` calls either. Create the async resilience middleware, and the `AsyncClient` holding it, once per event loop, for example inside the coroutine you pass to `asyncio.run()`:
371
+
372
+
```python
373
+
import asyncio
374
+
375
+
from httpware import AsyncClient
376
+
from httpware.middleware.resilience import AsyncCircuitBreaker, AsyncRetry
377
+
378
+
379
+
asyncdefmain() -> None:
380
+
asyncwith AsyncClient(
381
+
base_url="https://api.example.com",
382
+
middleware=[AsyncCircuitBreaker(), AsyncRetry()],
383
+
) as client:
384
+
await client.get("/users/1")
385
+
386
+
387
+
asyncio.run(main())
388
+
```
389
+
390
+
The usual way to hit the error is in tests: pytest-asyncio gives each test function its own loop by default, so an instance created at module level or in a session-scoped fixture fails in the second test that uses it. Create it in a function-scoped fixture or in the test.
391
+
392
+
To share a cap or a circuit across threads, use the sync classes with `Client`.
0 commit comments