Skip to content

Commit 8521a2d

Browse files
lesnik512claude
andauthored
feat(circuit-breaker): read-only state introspection (0.14.0) (#70)
* feat(circuit-breaker): public CircuitState enum + read-only state property Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(circuit-breaker): document state introspection; 0.14.0 release notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(planning): add the circuit-breaker-state change bundle Design + plan for the read-only state property + public CircuitState enum, and the Active Index entry. Bundle stays active/draft until merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4723acd commit 8521a2d

12 files changed

Lines changed: 532 additions & 13 deletions

File tree

architecture/resilience.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
`AsyncCircuitBreaker` and sync `CircuitBreaker` are a classic consecutive-failure circuit breaker: the circuit opens after `failure_threshold` consecutive counted failures, fast-fails while OPEN, admits one probe after `reset_timeout` (HALF_OPEN), and closes again after `success_threshold` consecutive probe successes; a probe failure re-opens it. A *counted failure* is a `NetworkError`, an httpware `TimeoutError`, or a `StatusError` whose `status_code` is in the effective failure set (default: all 5xx, 500–599); 4xx including 429 count as successes, and any other exception type propagates unchanged without affecting circuit state. When the breaker refuses a request — OPEN, or HALF_OPEN with the single probe slot already taken — it raises `CircuitOpenError` and never forwards to `next`; the error's `retry_after` carries the seconds until the next probe will be admitted, or `None` when a concurrent probe is already in flight. A breaker instance is sharable across clients (one shared circuit); a sync instance cannot be shared with an async one.
1616

17+
Both `AsyncCircuitBreaker` and `CircuitBreaker` expose a read-only `state` property that returns a public `CircuitState` enum (`CLOSED`/`OPEN`/`HALF_OPEN`), importable from `httpware`, for health checks and introspection. The property is a raw read of the stored state: because the OPEN→HALF_OPEN transition is lazy (it fires on the next request after `reset_timeout` elapses, not on a clock tick), `state` continues to report `OPEN` until a request is actually admitted as the probe — reading the property never triggers the transition.
18+
1719
The classic consecutive-failure mode is the default and unchanged. An opt-in time-based failure-rate mode is available: set `failure_rate_threshold` (a float in `(0, 1]`) to switch. In rate mode the circuit opens when the observed failure rate over a rolling `window_seconds` window (default `30.0` s) meets or exceeds the threshold, but only once `minimum_calls` outcomes have been observed in that window (default `20`). The presence of `failure_rate_threshold` is the sole mode switch: when it is set, the breaker is in rate mode and `failure_threshold` is ignored (setting both is not an error — rate mode wins). `window_seconds` and `minimum_calls` are validated at construction in both modes even though they are inert in classic mode, so an invalid value is rejected eagerly regardless of mode. Half-open recovery (`reset_timeout`, `success_threshold`, the single-probe admission) is identical to classic mode. The event names (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`) are the same in both modes; in rate mode the `circuit.opened` event carries extra attributes — `failure_rate`, `failure_rate_threshold`, `window_seconds`, `observed_calls` — and its message is `"circuit opened — failure rate threshold reached"`.
1820

1921
`AsyncTimeout` is an async-only middleware that bounds the total wall-clock for the whole inner pipeline (most importantly across an `AsyncRetry` loop, whose attempts and backoff sleeps `httpx2` cannot bound). It is not a per-call timeout — `httpx2`'s connect/read/write/pool timeouts are the right tool for a single outbound call, and `AsyncTimeout` does not duplicate them. It rejects a non-finite or non-positive `timeout` at construction, and on expiry raises httpware `TimeoutError`. There is no sync `Timeout`: a sync total-deadline cannot interrupt a blocking call mid-flight, and `httpx2` already covers sync per-call timeouts. Sync callers configure `httpx2`'s timeouts directly.

docs/resilience.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,22 @@ breaker = AsyncCircuitBreaker(
210210

211211
When `failure_rate_threshold` is set the breaker watches the rolling `window_seconds` window (default `30.0` s) and opens once the failure rate meets the threshold — provided at least `minimum_calls` (default `20`) outcomes have been observed in that window. Classic mode is the default; `failure_threshold` is ignored in rate mode. Half-open recovery works identically in both modes. The same `CircuitBreaker` constructor accepts the same parameters for sync clients.
212212

213+
### State introspection
214+
215+
Both `AsyncCircuitBreaker` and `CircuitBreaker` expose a read-only `state` property returning a public `CircuitState` enum:
216+
217+
```python
218+
from httpware import CircuitState
219+
from httpware.middleware.resilience import AsyncCircuitBreaker
220+
221+
breaker = AsyncCircuitBreaker(failure_threshold=5)
222+
# ... later, in a health/readiness handler:
223+
if breaker.state is CircuitState.OPEN:
224+
... # report the dependency as degraded
225+
```
226+
227+
`state` reflects the stored state at the moment of the call. It is read-only — writing to it raises `AttributeError`. The OPEN→HALF_OPEN transition is lazy: it fires on the next request admitted after `reset_timeout` elapses, not on a clock tick. So `state` will report `OPEN` until a request is actually admitted as the probe; reading it never triggers the transition. The same property exists on the sync `CircuitBreaker`.
228+
213229
### Sharing
214230

215231
Pass the same instance to multiple clients to enforce one shared circuit across them. A `CircuitBreaker` (sync) cannot be shared with an `AsyncCircuitBreaker` — they use different concurrency primitives.

planning/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ carry **no** frontmatter — living prose, dated by git.
7070

7171
### Active
7272

73-
_None._
73+
- **[circuit-breaker-state](changes/active/2026-06-16.03-circuit-breaker-state/design.md)** (2026-06-16) — Read-only `state` property + public `CircuitState` enum on the circuit breaker. Closes the cheap half of the deferred CircuitBreaker introspection item. Targets 0.14.0.
7474

7575
### Archived (shipped)
7676

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
status: draft
3+
date: 2026-06-16
4+
slug: circuit-breaker-state
5+
supersedes: null
6+
superseded_by: null
7+
pr: null
8+
outcome: null
9+
---
10+
11+
# Design: Read-only `state` introspection on the circuit breaker
12+
13+
## Summary
14+
15+
Expose the circuit breaker's current state through a typed public enum
16+
`CircuitState` and a read-only `state` property on `AsyncCircuitBreaker` /
17+
`CircuitBreaker`. Additive, no behavior change. Ships as 0.14.0.
18+
19+
## Motivation
20+
21+
The breaker currently has no way to ask "what state is the circuit in right
22+
now?" — useful for health/readiness endpoints, ops dashboards, and tests.
23+
Resilience4j (registry) and Polly (`StateProvider`) both expose this. It was
24+
parked under the CircuitBreaker deferred entry as the cheap, barely-speculative
25+
half of "manual control + state introspection" — explicitly the part worth
26+
building when convenient rather than parking indefinitely. The manual-control
27+
half (`force_open`/`force_closed`) stays deferred.
28+
29+
## Non-goals
30+
31+
- **Manual control** (`force_open`/`force_closed`) — stays deferred (YAGNI for
32+
an HTTP client).
33+
- **An "effective"/computed state** that reads the clock to report `HALF_OPEN`
34+
once `reset_timeout` has elapsed but before any request. The property is a
35+
pure read of the stored state (see Design §3).
36+
- **Per-call state** surfaced on responses or exceptions.
37+
38+
## Design
39+
40+
### 1. Promote `_CircuitState` → public `CircuitState`
41+
42+
The state enum is currently `_CircuitState` (private) in
43+
`src/httpware/middleware/resilience/circuit_breaker.py`, a `str`-valued enum
44+
with members `CLOSED = "closed"`, `OPEN = "open"`, `HALF_OPEN = "half_open"`.
45+
Rename it to `CircuitState` (drop the leading underscore), keeping the values,
46+
and update every internal reference. Because the old name was underscore-private,
47+
nothing external could depend on it — no deprecation shim needed.
48+
49+
Export it as a public symbol:
50+
- add `"CircuitState"` to `httpware.middleware.resilience.__all__` (alongside
51+
`AsyncCircuitBreaker`/`CircuitBreaker`),
52+
- add it to top-level `httpware.__all__` and the `httpware/__init__.py` imports,
53+
so `from httpware import CircuitState` works (mirroring how
54+
`AsyncCircuitBreaker` is already top-level re-exported).
55+
56+
### 2. The `state` property
57+
58+
A pure, side-effect-free read:
59+
60+
```python
61+
# on _CircuitBreakerState
62+
@property
63+
def state(self) -> CircuitState:
64+
return self._state
65+
66+
# on AsyncCircuitBreaker and CircuitBreaker
67+
@property
68+
def state(self) -> CircuitState:
69+
return self._state.state
70+
```
71+
72+
No lock and no clock read — a single attribute read of the stored enum. (A sync
73+
reader sees a momentary value; that is the nature of introspection and matches
74+
how Resilience4j/Polly expose it. No `threading.Lock` is taken for a single
75+
reference read.)
76+
77+
### 3. Raw stored-state semantics
78+
79+
The breaker transitions `OPEN → HALF_OPEN` *lazily*, inside `admit`, on the
80+
next request after `reset_timeout`. `state` reports the **raw stored** value, so
81+
between `reset_timeout` elapsing and the next request it still reads `OPEN`. This
82+
is deliberate: a property must not mutate (the lazy transition flips
83+
`_probe_in_flight` and the state) and must not duplicate the transition logic.
84+
The caveat is documented; for health-check use it is the honest answer ("the
85+
circuit is open; a probe will be admitted on the next call").
86+
87+
## Testing
88+
89+
Sync + async mirrors:
90+
91+
- `state` is `CircuitState.CLOSED` on a fresh breaker.
92+
- After enough counted failures to trip, `state` is `CircuitState.OPEN`.
93+
- After `reset_timeout` elapses AND a request is admitted as the probe, `state`
94+
is `CircuitState.HALF_OPEN`.
95+
- After `success_threshold` probe successes, `state` is back to
96+
`CircuitState.CLOSED`.
97+
- Raw-read caveat: with the circuit OPEN and `reset_timeout` elapsed but no
98+
request made, `state` still reads `OPEN` (pinned `_now` clock).
99+
- `from httpware import CircuitState` resolves; `"CircuitState"` is in
100+
`httpware.__all__` and `httpware.middleware.resilience.__all__`
101+
(extend the existing public-API test).
102+
103+
`just test` green; `just lint` clean.
104+
105+
## Risk
106+
107+
- **Rename churn (low × low).** Renaming `_CircuitState` touches every internal
108+
reference in `circuit_breaker.py`; a missed reference is a `NameError` caught
109+
immediately by the existing breaker suite + `ty`.
110+
- **Staleness confusion (low × low).** The raw-read caveat could surprise a user
111+
expecting `HALF_OPEN` the instant `reset_timeout` passes; mitigated by the doc
112+
note. Reporting raw stored state is the simpler, correct-for-a-property choice.
113+
114+
## Out of scope
115+
116+
Manual control, computed/effective state, response-level state — all excluded
117+
above. No change to trip behavior, event surface, or composition order.

0 commit comments

Comments
 (0)