|
| 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