Skip to content

docs: put the circuit breaker before AsyncTimeout; document rate-mode and event-loop behaviour - #178

Merged
lesnik512 merged 5 commits into
mainfrom
docs/composition-order
Oct 17, 2026
Merged

lesnik512 merged 5 commits into
mainfrom
docs/composition-order

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Composition order

The recommended async order changes from

AsyncTimeout → AsyncCircuitBreaker → AsyncBulkhead → AsyncRetry

to

AsyncCircuitBreaker → AsyncTimeout → AsyncBulkhead → AsyncRetry

With AsyncTimeout outside the breaker, the deadline fires while the breaker is awaiting its inner call. asyncio delivers that as a CancelledError inside the breaker, and the breaker classifies CancelledError as foreign: it leaves the circuit untouched. AsyncTimeout only turns the cancellation into an httpware TimeoutError after it has passed back through the breaker. The result is that an upstream that hangs never opens the circuit, however many calls time out. Only fast failures (5xx, connection errors) ever count.

With the breaker outside the timeout, the httpware TimeoutError is what reaches the breaker, and it is a counted failure. Nothing is lost by the move:

  • the breaker never waits, so starting the deadline after it costs nothing;
  • an open circuit still rejects at once, before the deadline starts;
  • the half-open probe still runs under the deadline, and a probe that times out now reopens the circuit (before, the cancellation only freed the probe slot and left the circuit HALF_OPEN);
  • the deadline still covers the bulkhead wait, every retry and every backoff.

I checked the old behaviour against main before changing anything: with [AsyncTimeout(0.01), AsyncCircuitBreaker(failure_threshold=2)] and a hanging transport, the third call is still sent and times out, and state is CLOSED. With the breaker first, the third call raises CircuitOpenError.

The order stays documented only. httpware still does not enforce it at construction. The sync order (CircuitBreaker → Bulkhead → Retry) is unchanged, since there is no sync Timeout and httpx2's per-request timeouts are raised inside the breaker in any order.

Updated: the Composition section and its example, the AsyncTimeout section, a note in the breaker's failure classification, and the AsyncTimeout docstrings, which said "outermost".

Full-stack demo

The demo engine modelled the old order: a timed-out operation was never recorded by the breaker. It now records one as a failure (brk.res(false, now)), and the deadline still starts only after the breaker admits the request, so an open circuit rejects before any deadline. The order comments in engine.js match the new chain.

With that change the scenario told the wrong story. The 4s latency spike in phase 1 opened the breaker, which then stayed open or half-open through the brownout, so the "retry recovers transient errors" beat never happened and the "hard down" stop fired during phase 2. I swapped the first two phases. The incident now runs brownout (2 to 5s), latency spike (5 to 8s), then hard down (8 to 12s), and the walkthrough has a new stop where timeouts open the breaker. Traced in the browser: the breaker stays closed through the brownout, opens at 6.97s from expired deadlines, its probe fails during hard down (reopens at 10.03s), and it closes at 12.24s. The page and chain badge state the new order. No other demo combines a timeout with a breaker.

ADR-0004, updated in place

Its argument against a slow-call rate was that AsyncTimeout already turns a slow call into a counted TimeoutError. Under the old recommended order that was false for the reason above. The ADR now says the argument depends on the breaker sitting outside the deadline, names the order it relies on, and notes that httpware documents but does not enforce that order.

Rate mode can open on a success

In rate mode, once the rolling window holds minimum_calls outcomes, the failure rate is checked after every recorded outcome, successes included (_record_outcome records, then checks, for both kinds). So a success can be the call that opens the circuit: with minimum_calls=4 and a threshold of 0.5, three failures leave it closed, and a fourth call returning 200 makes the rate 3/4 and opens it. That call still returns its response; the next one gets CircuitOpenError. This matches Resilience4j. Behaviour unchanged, now documented in the rate-mode section. An existing test already pins it (test_circuit_breaker.py, two failures then two successes open the circuit).

Clearer cross-loop error

AsyncBulkhead, AsyncCircuitBreaker and AsyncKeyedCircuitBreaker stay strictly bound to the first event loop they run on. Only the RuntimeError text changes. It now says the instance is bound to the loop it first ran on (keeping both loop reprs), that async resilience middleware and the AsyncClient holding it must be created once per event loop, for example inside the coroutine passed to asyncio.run(), and that sharing an instance across pytest-asyncio's function-scoped loops is the usual way to hit it. The old hint about the sync primitives for cross-thread use moved to the docs, since a sync breaker can't be used with an AsyncClient anyway.

docs/resilience.md gains a short "One event loop per instance" section covering the same ground, including that httpx2.AsyncClient also fails across two asyncio.run() calls ("Event loop is closed").

Release notes (0.22.0)

Not breaking.

  • Docs: the recommended async order is now AsyncCircuitBreaker → AsyncTimeout → AsyncBulkhead → AsyncRetry, so the breaker counts expired deadlines. Chains that follow the old order keep working but never open the circuit on timeouts; swap the first two entries.
  • The RuntimeError raised when an async bulkhead or circuit breaker is used from a second event loop has a new message. Code that matched the old "bound to a single event loop" text needs updating.

Tests

  • New test_breaker_before_timeout_counts_expired_deadlines: a hanging transport behind [breaker, AsyncTimeout(0.01)] opens the circuit after two timeouts. Swapping the order makes it fail (the third call raises TimeoutError instead of CircuitOpenError). Async only, since AsyncTimeout has no sync sibling.
  • The three cross-loop tests assert the new message text. They were updated first and failed against the old message before the constants changed.
  • just lint, just lint-ci, just test-ci: 1196 passed, 100% coverage. just docs-build passes.
  • Demos: served the built site locally and ran the full-stack and timeout demos to the end in a browser. Every walkthrough stop fired in order, and the console showed no errors.

No effect on the request path: only the error-path message string and docs changed.

@lesnik512
lesnik512 merged commit 7b41094 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the docs/composition-order branch October 17, 2026 09:35
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