Skip to content

Commit b4ba5ae

Browse files
lesnik512claude
andcommitted
docs(resilience): correct composition order — AsyncBulkhead outside AsyncRetry
The recommended chain wrongly placed AsyncBulkhead inside AsyncRetry, contradicting the tested [AsyncBulkhead, AsyncRetry] guidance (one slot per logical call). Correct order: AsyncTimeout -> AsyncCircuitBreaker -> AsyncBulkhead -> AsyncRetry -> terminal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2dfa367 commit b4ba5ae

4 files changed

Lines changed: 9 additions & 9 deletions

File tree

docs/resilience.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
- **`RetryBudget`** — Finagle-style token bucket; safe to share across sync `Client` and `AsyncClient` in the same process. (Finagle-style bounds the global retry rate to prevent retry storms when downstreams degrade.)
77
- **`Bulkhead` / `AsyncBulkhead`** — concurrency limiter with bounded acquire-wait (`threading.Semaphore` and `asyncio.Semaphore` respectively)
88

9-
A key ordering constraint: `AsyncBulkhead` must sit inside `AsyncRetry` so one slot covers all retry attempts of a single call. For the full recommended ordering across all four primitives, see [Composition](#composition). Reach for the [Middleware guide](middleware.md) when you want to write your own resilience policy.
9+
A key ordering constraint: `AsyncBulkhead` must sit outside `AsyncRetry` (before it in `middleware=`) so one slot covers all retry attempts of a single call. For the full recommended ordering across all four primitives, see [Composition](#composition). Reach for the [Middleware guide](middleware.md) when you want to write your own resilience policy.
1010

1111
## `AsyncRetry`
1212

@@ -263,12 +263,12 @@ async with AsyncClient(
263263
The recommended ordering (not enforced, but each position has a reason):
264264

265265
```
266-
AsyncTimeout → AsyncCircuitBreaker → AsyncRetryAsyncBulkhead → terminal
266+
AsyncTimeout → AsyncCircuitBreaker → AsyncBulkheadAsyncRetry → terminal
267267
```
268268

269269
- `AsyncTimeout` outermost so the overall deadline covers the entire sequence including retries and backoff.
270-
- `AsyncCircuitBreaker` outside `AsyncRetry` so an open circuit short-circuits the whole retry loop without attempting any calls. This also means the breaker counts one outcome per fully-exhausted retry sequence rather than one per individual attempt.
271-
- `AsyncBulkhead` inside `AsyncRetry` so one slot covers all retry attempts of a single call. Flip it (`[AsyncRetry, AsyncBulkhead]`) and each retry grabs a fresh slot — defeating the bulkhead under load.
270+
- `AsyncCircuitBreaker` outside `AsyncRetry` so an open circuit short-circuits the whole retry loop without attempting any calls. This also means the breaker counts one outcome per fully-exhausted retry sequence rather than one per individual attempt. Placing it outside `AsyncBulkhead` too means a request the open circuit rejects never consumes a concurrency slot.
271+
- `AsyncBulkhead` outside `AsyncRetry` so one slot covers all retry attempts of a single call. Flip those two (`[AsyncRetry, AsyncBulkhead]`) and each retry grabs a fresh slot — defeating the bulkhead under load.
272272

273273
```python
274274
from httpware import AsyncClient
@@ -286,8 +286,8 @@ async def main() -> None:
286286
middleware=[
287287
AsyncTimeout(timeout=30.0),
288288
AsyncCircuitBreaker(),
289-
AsyncRetry(),
290289
AsyncBulkhead(max_concurrent=10),
290+
AsyncRetry(),
291291
],
292292
) as client:
293293
await client.get("/users/1")

planning/plans/2026-06-13-circuit-breaker-and-timeout.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1490,7 +1490,7 @@ Add a `## Overall timeout (async only)` section: what it bounds (total wall-cloc
14901490
Add a short subsection (or extend the existing ordering guidance) documenting the recommended chain order and that it is **not enforced**:
14911491

14921492
```
1493-
AsyncTimeout → AsyncCircuitBreaker → AsyncRetryAsyncBulkhead → terminal
1493+
AsyncTimeout → AsyncCircuitBreaker → AsyncBulkheadAsyncRetry → terminal
14941494
```
14951495

14961496
Explain the consequence of breaker-outside-retry: an open circuit short-circuits the whole retry loop, and the breaker counts one outcome per fully-exhausted retry sequence.

planning/releases/0.10.0.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Bounds total wall-clock across the inner pipeline — including retries and back
3838
## Recommended chain ordering
3939

4040
```
41-
AsyncTimeout → AsyncCircuitBreaker → AsyncRetryAsyncBulkhead → terminal
41+
AsyncTimeout → AsyncCircuitBreaker → AsyncBulkheadAsyncRetry → terminal
4242
```
4343

4444
Breaker outside retry: an open circuit short-circuits the whole retry loop; the breaker counts one outcome per fully-exhausted retry sequence.

planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Both are pure stdlib (`asyncio.timeout`, `time.monotonic`, `threading.Lock`, `en
2121
2. **The breaker v1 trips on consecutive failures** (Polly *classic* breaker): open after `failure_threshold` consecutive counted failures → probe after `reset_timeout` → close after `success_threshold` consecutive half-open successes. Rolling-window / failure-rate (Resilience4j / Polly-v8 default) is **deferred to v2**; the config is shaped so adding a `window` mode later is purely additive.
2222
3. **Failure classification = 5xx + network + timeout, excluding 429.** A *counted failure* is `NetworkError`, httpware `TimeoutError`, or a `StatusError` whose `status_code` is in the effective failure set (default = all 5xx, 500–599). 4xx including 429 do **not** trip the breaker (429 = healthy-but-throttling; tripping amplifies the incident) and count as breaker *successes*. Any other exception type (e.g. `BulkheadFullError`, `ValueError`) propagates unchanged and does **not** affect circuit state.
2323
4. **Control surface is events-only (YAGNI).** No public `state` property, no `reset()` / `isolate()`. Monitoring goes through the observability events. State introspection and manual control can be added additively in a later release if a concrete consumer demand surfaces.
24-
5. **Recommended ordering is breaker-outside-retry.** Documented (not enforced): `AsyncTimeout → AsyncCircuitBreaker → AsyncRetryAsyncBulkhead → terminal`. With the breaker outside retry, an open circuit short-circuits the *entire* retry loop (don't hammer a service that's already down), and the breaker counts one outcome per fully-exhausted retry sequence rather than per attempt.
24+
5. **Recommended ordering is breaker-outside-retry.** Documented (not enforced): `AsyncTimeout → AsyncCircuitBreaker → AsyncBulkheadAsyncRetry → terminal` (corrected during implementation: AsyncBulkhead sits outside AsyncRetry to keep one slot per logical call, consistent with the existing `test_bulkhead_outside_retry_holds_one_slot_across_attempts` guidance). With the breaker outside retry, an open circuit short-circuits the *entire* retry loop (don't hammer a service that's already down), and the breaker counts one outcome per fully-exhausted retry sequence rather than per attempt.
2525

2626
## Non-goals
2727

@@ -296,7 +296,7 @@ TDD, 100% branch coverage enforced (`--cov-fail-under=100`). `httpx2.MockTranspo
296296

297297
## Docs + release
298298

299-
- **`docs/resilience.md`:** a CircuitBreaker section and an AsyncTimeout section; the recommended ordering `AsyncTimeout → AsyncCircuitBreaker → AsyncRetryAsyncBulkhead → terminal` (documented, not enforced); the rationale notes ("why no sync Timeout", "why not duplicate httpx2 per-call timeouts", "429/4xx count as successes, not failures").
299+
- **`docs/resilience.md`:** a CircuitBreaker section and an AsyncTimeout section; the recommended ordering `AsyncTimeout → AsyncCircuitBreaker → AsyncBulkheadAsyncRetry → terminal` (documented, not enforced); the rationale notes ("why no sync Timeout", "why not duplicate httpx2 per-call timeouts", "429/4xx count as successes, not failures").
300300
- **`README.md`:** extend the resilience paragraph from "Retry + Bulkhead" to include CircuitBreaker + AsyncTimeout.
301301
- **`planning/releases/0.10.0.md`:** new release notes (additive minor; new public names; new observability events).
302302

0 commit comments

Comments
 (0)