From f1bb1f49a7f9b8cdc6ae5ad3e7e78e50dbf38dfd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 15:30:53 +0300 Subject: [PATCH 1/3] feat(circuit-breaker): public CircuitState enum + read-only state property Co-Authored-By: Claude Opus 4.8 (1M context) --- src/httpware/__init__.py | 2 + .../middleware/resilience/__init__.py | 3 +- .../middleware/resilience/circuit_breaker.py | 45 ++++++++++++++----- tests/test_circuit_breaker.py | 37 +++++++++++++++ tests/test_circuit_breaker_sync.py | 37 +++++++++++++++ tests/test_public_api.py | 8 ++++ 6 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 69cfa61..3c631f0 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -46,6 +46,7 @@ AsyncTimeout, Bulkhead, CircuitBreaker, + CircuitState, Retry, RetryBudget, ) @@ -65,6 +66,7 @@ "BulkheadFullError", "CircuitBreaker", "CircuitOpenError", + "CircuitState", "Client", "ClientError", "ClientStatusError", diff --git a/src/httpware/middleware/resilience/__init__.py b/src/httpware/middleware/resilience/__init__.py index cdde36e..a85ab68 100644 --- a/src/httpware/middleware/resilience/__init__.py +++ b/src/httpware/middleware/resilience/__init__.py @@ -2,7 +2,7 @@ from httpware.middleware.resilience.budget import RetryBudget from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead -from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker +from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker, CircuitState from httpware.middleware.resilience.retry import AsyncRetry, Retry from httpware.middleware.resilience.timeout import AsyncTimeout @@ -14,6 +14,7 @@ "AsyncTimeout", "Bulkhead", "CircuitBreaker", + "CircuitState", "Retry", "RetryBudget", ] diff --git a/src/httpware/middleware/resilience/circuit_breaker.py b/src/httpware/middleware/resilience/circuit_breaker.py index 3c530ca..25ebda2 100644 --- a/src/httpware/middleware/resilience/circuit_breaker.py +++ b/src/httpware/middleware/resilience/circuit_breaker.py @@ -70,7 +70,9 @@ _LOGGER = logging.getLogger("httpware.circuit_breaker") -class _CircuitState(enum.Enum): +class CircuitState(enum.Enum): + """Lifecycle state of a circuit breaker: CLOSED, OPEN, or HALF_OPEN.""" + CLOSED = "closed" OPEN = "open" HALF_OPEN = "half_open" @@ -172,7 +174,7 @@ def __init__( # noqa: PLR0913 — breaker state has many orthogonal knobs; a da self._window = _RollingWindow(window_seconds) if self._rate_mode else None self._window_seconds = window_seconds self._now = now - self._state = _CircuitState.CLOSED + self._state = CircuitState.CLOSED self._consecutive_failures = 0 self._consecutive_successes = 0 self._opened_at = 0.0 @@ -181,14 +183,19 @@ def __init__( # noqa: PLR0913 — breaker state has many orthogonal knobs; a da def is_failure_status(self, status_code: int) -> bool: return status_code in self._failure_status_codes + @property + def state(self) -> CircuitState: + """The circuit's current stored state (raw read; no lazy OPEN→HALF_OPEN transition).""" + return self._state + def admit(self, request: httpx2.Request) -> str: """Decide the request's role, or raise CircuitOpenError. No await inside.""" - if self._state is _CircuitState.CLOSED: + if self._state is CircuitState.CLOSED: return _ROLE_CLOSED - if self._state is _CircuitState.OPEN: + if self._state is CircuitState.OPEN: elapsed = self._now() - self._opened_at if elapsed >= self._reset_timeout: - self._state = _CircuitState.HALF_OPEN + self._state = CircuitState.HALF_OPEN self._probe_in_flight = True self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {}) return _ROLE_PROBE @@ -217,15 +224,15 @@ def admit(self, request: httpx2.Request) -> str: def on_success(self, role: str, request: httpx2.Request) -> None: if role == _ROLE_PROBE: self._probe_in_flight = False - if self._state is _CircuitState.CLOSED: + if self._state is CircuitState.CLOSED: if self._rate_mode: self._record_outcome(request, failed=False) else: self._consecutive_failures = 0 - elif self._state is _CircuitState.HALF_OPEN: + elif self._state is CircuitState.HALF_OPEN: self._consecutive_successes += 1 if self._consecutive_successes >= self._success_threshold: - self._state = _CircuitState.CLOSED + self._state = CircuitState.CLOSED self._consecutive_failures = 0 self._consecutive_successes = 0 if self._rate_mode: @@ -235,14 +242,14 @@ def on_success(self, role: str, request: httpx2.Request) -> None: def on_failure(self, role: str, request: httpx2.Request) -> None: if role == _ROLE_PROBE: self._probe_in_flight = False - if self._state is _CircuitState.CLOSED: + if self._state is CircuitState.CLOSED: if self._rate_mode: self._record_outcome(request, failed=True) else: self._consecutive_failures += 1 if self._consecutive_failures >= self._failure_threshold: self._open(request, failures=self._consecutive_failures) - elif self._state is _CircuitState.HALF_OPEN: + elif self._state is CircuitState.HALF_OPEN: self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit def release_probe(self, role: str) -> None: @@ -251,7 +258,7 @@ def release_probe(self, role: str) -> None: self._probe_in_flight = False def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None: - self._state = _CircuitState.OPEN + self._state = CircuitState.OPEN self._opened_at = self._now() self._consecutive_failures = 0 self._consecutive_successes = 0 @@ -346,6 +353,14 @@ def _check_loop(self) -> None: elif self._loop is not current: # pragma: no cover raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current)) + @property + def state(self) -> CircuitState: + """Current circuit state — CLOSED, OPEN, or HALF_OPEN. + + Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free). + """ + return self._state.state + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002 """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed.""" self._check_loop() @@ -399,6 +414,14 @@ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclas ) self._lock = threading.Lock() + @property + def state(self) -> CircuitState: + """Current circuit state — CLOSED, OPEN, or HALF_OPEN. + + Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free). + """ + return self._state.state + def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed.""" with self._lock: diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py index 0ac0796..c343621 100644 --- a/tests/test_circuit_breaker.py +++ b/tests/test_circuit_breaker.py @@ -18,6 +18,7 @@ from httpware import ( AsyncClient, CircuitOpenError, + CircuitState, InternalServerError, NetworkError, NotFoundError, @@ -632,3 +633,39 @@ async def test_rate_mode_open_event_carries_rate_attributes(caplog: pytest.LogCa assert rec.observed_calls >= 4 # noqa: PLR2004 # ty: ignore[unresolved-attribute] assert hasattr(rec, "failure_rate") assert not hasattr(rec, "failure_threshold") # classic attribute absent in rate mode + + +# ── state property ── + + +async def test_state_closed_open_and_raw_read_caveat() -> None: + clock = _Clock() + breaker = AsyncCircuitBreaker(failure_threshold=2, reset_timeout=10.0, success_threshold=1, _now=clock) + assert breaker.state is CircuitState.CLOSED + client = _client(_StatusSequence([500, 500]), breaker=breaker) + for _ in range(2): + with pytest.raises(InternalServerError): + await client.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + # raw-read caveat: reset_timeout elapses but NO request is made → still OPEN + clock.advance(10.0) + assert breaker.state is CircuitState.OPEN + # the next request is admitted as the probe and (success_threshold=1) closes the circuit + ok = _client(_StatusSequence([200]), breaker=breaker) + assert (await ok.get("https://example.test/x")).status_code == HTTPStatus.OK + assert breaker.state is CircuitState.CLOSED + + +async def test_state_half_open_while_probing() -> None: + clock = _Clock() + breaker = AsyncCircuitBreaker(failure_threshold=1, reset_timeout=5.0, success_threshold=2, _now=clock) + fail = _client(_StatusSequence([500]), breaker=breaker) + with pytest.raises(InternalServerError): + await fail.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + clock.advance(5.0) + ok = _client(_StatusSequence([200, 200]), breaker=breaker) + await ok.get("https://example.test/x") # admitted as probe; 1 success, needs 2 → HALF_OPEN + assert breaker.state is CircuitState.HALF_OPEN + await ok.get("https://example.test/x") # 2nd consecutive success → CLOSED + assert breaker.state is CircuitState.CLOSED diff --git a/tests/test_circuit_breaker_sync.py b/tests/test_circuit_breaker_sync.py index b80664c..2509118 100644 --- a/tests/test_circuit_breaker_sync.py +++ b/tests/test_circuit_breaker_sync.py @@ -11,6 +11,7 @@ from httpware import ( CircuitOpenError, + CircuitState, Client, InternalServerError, NetworkError, @@ -523,3 +524,39 @@ def test_rate_mode_clears_window_on_close() -> None: fail_client.get("https://example.test/x") ok_client = _client(_StatusSequence([200]), breaker=breaker) assert ok_client.get("https://example.test/x").status_code == HTTPStatus.OK + + +# ── state property (sync mirror) ── + + +def test_state_closed_open_and_raw_read_caveat() -> None: + clock = _Clock() + breaker = CircuitBreaker(failure_threshold=2, reset_timeout=10.0, success_threshold=1, _now=clock) + assert breaker.state is CircuitState.CLOSED + client = _client(_StatusSequence([500, 500]), breaker=breaker) + for _ in range(2): + with pytest.raises(InternalServerError): + client.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + # raw-read caveat: reset_timeout elapses but NO request is made → still OPEN + clock.advance(10.0) + assert breaker.state is CircuitState.OPEN + # the next request is admitted as the probe and (success_threshold=1) closes the circuit + ok = _client(_StatusSequence([200]), breaker=breaker) + assert ok.get("https://example.test/x").status_code == HTTPStatus.OK + assert breaker.state is CircuitState.CLOSED + + +def test_state_half_open_while_probing() -> None: + clock = _Clock() + breaker = CircuitBreaker(failure_threshold=1, reset_timeout=5.0, success_threshold=2, _now=clock) + fail = _client(_StatusSequence([500]), breaker=breaker) + with pytest.raises(InternalServerError): + fail.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + clock.advance(5.0) + ok = _client(_StatusSequence([200, 200]), breaker=breaker) + ok.get("https://example.test/x") # admitted as probe; 1 success, needs 2 → HALF_OPEN + assert breaker.state is CircuitState.HALF_OPEN + ok.get("https://example.test/x") # 2nd consecutive success → CLOSED + assert breaker.state is CircuitState.CLOSED diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 94b27ae..94ccf8e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -2,6 +2,8 @@ import httpware import httpware.middleware +from httpware import CircuitState +from httpware.middleware.resilience import CircuitState as ResilienceCircuitState def test_all_exports_resolve() -> None: @@ -41,6 +43,7 @@ def test_expected_exports() -> None: "BulkheadFullError", "CircuitBreaker", "CircuitOpenError", + "CircuitState", "Client", "ClientError", "ClientStatusError", @@ -80,6 +83,11 @@ def test_expected_exports() -> None: ) +def test_circuit_state_exported() -> None: + assert CircuitState is ResilienceCircuitState + assert {m.value for m in CircuitState} == {"closed", "open", "half_open"} + + def test_missing_decoder_error_exported() -> None: assert "MissingDecoderError" in httpware.__all__ assert httpware.MissingDecoderError.__module__ == "httpware.errors" From 374821f1131fb56df38ee39ecc03e37d0bf33b3c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 15:36:49 +0300 Subject: [PATCH 2/3] docs(circuit-breaker): document state introspection; 0.14.0 release notes Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/resilience.md | 2 ++ docs/resilience.md | 16 ++++++++++++ planning/releases/0.14.0.md | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 planning/releases/0.14.0.md diff --git a/architecture/resilience.md b/architecture/resilience.md index 7e91288..9b6d8f1 100644 --- a/architecture/resilience.md +++ b/architecture/resilience.md @@ -14,6 +14,8 @@ `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. +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. + 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"`. `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. diff --git a/docs/resilience.md b/docs/resilience.md index ff00b2a..9359a35 100644 --- a/docs/resilience.md +++ b/docs/resilience.md @@ -210,6 +210,22 @@ breaker = AsyncCircuitBreaker( 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. +### State introspection + +Both `AsyncCircuitBreaker` and `CircuitBreaker` expose a read-only `state` property returning a public `CircuitState` enum: + +```python +from httpware import CircuitState +from httpware.middleware.resilience import AsyncCircuitBreaker + +breaker = AsyncCircuitBreaker(failure_threshold=5) +# ... later, in a health/readiness handler: +if breaker.state is CircuitState.OPEN: + ... # report the dependency as degraded +``` + +`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`. + ### Sharing 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. diff --git a/planning/releases/0.14.0.md b/planning/releases/0.14.0.md new file mode 100644 index 0000000..d4762b2 --- /dev/null +++ b/planning/releases/0.14.0.md @@ -0,0 +1,50 @@ +# httpware 0.14.0 — read-only circuit-breaker state introspection + +**Minor release. Additive only — no breaking changes.** + +This release adds a read-only `state` property and a public `CircuitState` enum +to both `AsyncCircuitBreaker` and `CircuitBreaker`, enabling health checks, +readiness probes, dashboards, and test assertions against the current circuit +state without any impact on circuit behavior. + +## New behavior + +Both breakers now expose a `state` property that returns one of three values from +the new `CircuitState` enum (`CLOSED`, `OPEN`, `HALF_OPEN`). The enum is exported +from the top-level `httpware` package: + +```python +from httpware import CircuitState +from httpware.middleware.resilience import AsyncCircuitBreaker + +breaker = AsyncCircuitBreaker(failure_threshold=5) + +# In a health or readiness handler: +if breaker.state is CircuitState.OPEN: + ... # report the dependency as degraded +``` + +The same property exists on the sync `CircuitBreaker`. + +## Semantics + +`state` is a raw read of the stored state. The OPEN→HALF_OPEN transition is lazy: +it fires on the next request admitted after `reset_timeout` elapses, not on a +clock tick. This means `state` will report `OPEN` until a request is actually +admitted as the probe — reading the property never triggers the transition. + +This is intentional. A health endpoint that polls `state` cannot accidentally +promote the circuit to HALF_OPEN by reading it; only real traffic does. + +## What is NOT in this release + +The following remain deferred and are not part of 0.14.0: + +- Manual circuit control (`force_open`, `force_closed`) +- Writable state transitions (e.g., reset via property assignment) +- Count-based sliding windows +- Slow-call detection + +## Shipped via + +PR #XX — read-only circuit-breaker state introspection. From 5453e0f2611861495f3553622e7ed28c3b18ba66 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 15:39:05 +0300 Subject: [PATCH 3/3] 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) --- planning/README.md | 2 +- .../design.md | 117 +++++++++ .../plan.md | 226 ++++++++++++++++++ 3 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 planning/changes/active/2026-06-16.03-circuit-breaker-state/design.md create mode 100644 planning/changes/active/2026-06-16.03-circuit-breaker-state/plan.md diff --git a/planning/README.md b/planning/README.md index c7dd471..60d9b47 100644 --- a/planning/README.md +++ b/planning/README.md @@ -70,7 +70,7 @@ carry **no** frontmatter — living prose, dated by git. ### Active -_None._ +- **[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. ### Archived (shipped) diff --git a/planning/changes/active/2026-06-16.03-circuit-breaker-state/design.md b/planning/changes/active/2026-06-16.03-circuit-breaker-state/design.md new file mode 100644 index 0000000..982a26d --- /dev/null +++ b/planning/changes/active/2026-06-16.03-circuit-breaker-state/design.md @@ -0,0 +1,117 @@ +--- +status: draft +date: 2026-06-16 +slug: circuit-breaker-state +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Design: Read-only `state` introspection on the circuit breaker + +## Summary + +Expose the circuit breaker's current state through a typed public enum +`CircuitState` and a read-only `state` property on `AsyncCircuitBreaker` / +`CircuitBreaker`. Additive, no behavior change. Ships as 0.14.0. + +## Motivation + +The breaker currently has no way to ask "what state is the circuit in right +now?" — useful for health/readiness endpoints, ops dashboards, and tests. +Resilience4j (registry) and Polly (`StateProvider`) both expose this. It was +parked under the CircuitBreaker deferred entry as the cheap, barely-speculative +half of "manual control + state introspection" — explicitly the part worth +building when convenient rather than parking indefinitely. The manual-control +half (`force_open`/`force_closed`) stays deferred. + +## Non-goals + +- **Manual control** (`force_open`/`force_closed`) — stays deferred (YAGNI for + an HTTP client). +- **An "effective"/computed state** that reads the clock to report `HALF_OPEN` + once `reset_timeout` has elapsed but before any request. The property is a + pure read of the stored state (see Design §3). +- **Per-call state** surfaced on responses or exceptions. + +## Design + +### 1. Promote `_CircuitState` → public `CircuitState` + +The state enum is currently `_CircuitState` (private) in +`src/httpware/middleware/resilience/circuit_breaker.py`, a `str`-valued enum +with members `CLOSED = "closed"`, `OPEN = "open"`, `HALF_OPEN = "half_open"`. +Rename it to `CircuitState` (drop the leading underscore), keeping the values, +and update every internal reference. Because the old name was underscore-private, +nothing external could depend on it — no deprecation shim needed. + +Export it as a public symbol: +- add `"CircuitState"` to `httpware.middleware.resilience.__all__` (alongside + `AsyncCircuitBreaker`/`CircuitBreaker`), +- add it to top-level `httpware.__all__` and the `httpware/__init__.py` imports, + so `from httpware import CircuitState` works (mirroring how + `AsyncCircuitBreaker` is already top-level re-exported). + +### 2. The `state` property + +A pure, side-effect-free read: + +```python +# on _CircuitBreakerState +@property +def state(self) -> CircuitState: + return self._state + +# on AsyncCircuitBreaker and CircuitBreaker +@property +def state(self) -> CircuitState: + return self._state.state +``` + +No lock and no clock read — a single attribute read of the stored enum. (A sync +reader sees a momentary value; that is the nature of introspection and matches +how Resilience4j/Polly expose it. No `threading.Lock` is taken for a single +reference read.) + +### 3. Raw stored-state semantics + +The breaker transitions `OPEN → HALF_OPEN` *lazily*, inside `admit`, on the +next request after `reset_timeout`. `state` reports the **raw stored** value, so +between `reset_timeout` elapsing and the next request it still reads `OPEN`. This +is deliberate: a property must not mutate (the lazy transition flips +`_probe_in_flight` and the state) and must not duplicate the transition logic. +The caveat is documented; for health-check use it is the honest answer ("the +circuit is open; a probe will be admitted on the next call"). + +## Testing + +Sync + async mirrors: + +- `state` is `CircuitState.CLOSED` on a fresh breaker. +- After enough counted failures to trip, `state` is `CircuitState.OPEN`. +- After `reset_timeout` elapses AND a request is admitted as the probe, `state` + is `CircuitState.HALF_OPEN`. +- After `success_threshold` probe successes, `state` is back to + `CircuitState.CLOSED`. +- Raw-read caveat: with the circuit OPEN and `reset_timeout` elapsed but no + request made, `state` still reads `OPEN` (pinned `_now` clock). +- `from httpware import CircuitState` resolves; `"CircuitState"` is in + `httpware.__all__` and `httpware.middleware.resilience.__all__` + (extend the existing public-API test). + +`just test` green; `just lint` clean. + +## Risk + +- **Rename churn (low × low).** Renaming `_CircuitState` touches every internal + reference in `circuit_breaker.py`; a missed reference is a `NameError` caught + immediately by the existing breaker suite + `ty`. +- **Staleness confusion (low × low).** The raw-read caveat could surprise a user + expecting `HALF_OPEN` the instant `reset_timeout` passes; mitigated by the doc + note. Reporting raw stored state is the simpler, correct-for-a-property choice. + +## Out of scope + +Manual control, computed/effective state, response-level state — all excluded +above. No change to trip behavior, event surface, or composition order. diff --git a/planning/changes/active/2026-06-16.03-circuit-breaker-state/plan.md b/planning/changes/active/2026-06-16.03-circuit-breaker-state/plan.md new file mode 100644 index 0000000..62df25f --- /dev/null +++ b/planning/changes/active/2026-06-16.03-circuit-breaker-state/plan.md @@ -0,0 +1,226 @@ +--- +status: draft +date: 2026-06-16 +slug: circuit-breaker-state +spec: circuit-breaker-state +pr: null +--- + +# circuit-breaker-state — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the circuit breaker's state via a public `CircuitState` enum +and a read-only `state` property on `AsyncCircuitBreaker` / `CircuitBreaker`. + +**Architecture:** Promote the existing private `_CircuitState` enum to public +`CircuitState`, export it (resilience package + top-level `httpware`), and add a +pure read-only `state` property to the shared `_CircuitBreakerState` and both +wrappers. No behavior change; raw stored-state read (no clock, no lock). + +**Tech Stack:** Python 3.11+, `httpx2`, `pytest` (asyncio auto mode), injected +`_now` clock for deterministic state tests. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/circuit-breaker-state` + +**Commit strategy:** Per-task commits. + +--- + +### Task 1: Promote `CircuitState`, add the `state` property, export it + +**Files:** +- Modify: `src/httpware/middleware/resilience/circuit_breaker.py` +- Modify: `src/httpware/middleware/resilience/__init__.py` +- Modify: `src/httpware/__init__.py` +- Test: `tests/test_circuit_breaker.py`, `tests/test_circuit_breaker_sync.py`, `tests/test_public_api.py` + +- [ ] **Step 1: Write the failing tests (async behavior + public API)** + + Append to `tests/test_circuit_breaker.py` (reuses `_Clock`, `_StatusSequence`, `_client`, `InternalServerError`, `CircuitOpenError`, `HTTPStatus`, `pytest`; add `CircuitState` to the existing `from httpware...` import or import it via `from httpware import CircuitState`): + + ```python + from httpware import CircuitState + + + async def test_state_closed_open_and_raw_read_caveat() -> None: + clock = _Clock() + breaker = AsyncCircuitBreaker(failure_threshold=2, reset_timeout=10.0, success_threshold=1, _now=clock) + assert breaker.state is CircuitState.CLOSED + client = _client(_StatusSequence([500, 500]), breaker=breaker) + for _ in range(2): + with pytest.raises(InternalServerError): + await client.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + # raw-read caveat: reset_timeout elapses but NO request is made → still OPEN + clock.advance(10.0) + assert breaker.state is CircuitState.OPEN + # the next request is admitted as the probe and (success_threshold=1) closes the circuit + ok = _client(_StatusSequence([200]), breaker=breaker) + assert (await ok.get("https://example.test/x")).status_code == HTTPStatus.OK + assert breaker.state is CircuitState.CLOSED + + + async def test_state_half_open_while_probing() -> None: + clock = _Clock() + breaker = AsyncCircuitBreaker(failure_threshold=1, reset_timeout=5.0, success_threshold=2, _now=clock) + fail = _client(_StatusSequence([500]), breaker=breaker) + with pytest.raises(InternalServerError): + await fail.get("https://example.test/x") + assert breaker.state is CircuitState.OPEN + clock.advance(5.0) + ok = _client(_StatusSequence([200, 200]), breaker=breaker) + await ok.get("https://example.test/x") # admitted as probe; 1 success, needs 2 → HALF_OPEN + assert breaker.state is CircuitState.HALF_OPEN + await ok.get("https://example.test/x") # 2nd consecutive success → CLOSED + assert breaker.state is CircuitState.CLOSED + ``` + + Add to `tests/test_public_api.py`: insert `"CircuitState",` into the `expected` set in `test_expected_exports`, keeping alphabetical order — it sorts after `"CircuitOpenError"` and before `"Client"` (`CircuitState` < `Client` because `Circ` < `Cli`). Then add a focused test: + + ```python + def test_circuit_state_exported() -> None: + from httpware import CircuitState + from httpware.middleware.resilience import CircuitState as ResilienceCircuitState + + assert CircuitState is ResilienceCircuitState + assert {m.value for m in CircuitState} == {"closed", "open", "half_open"} + ``` + +- [ ] **Step 2: Run to verify failure** + + Run: `just test tests/test_circuit_breaker.py -k state && just test tests/test_public_api.py -k circuit_state` + Expected: FAIL — `ImportError: cannot import name 'CircuitState'` / `AttributeError: ... has no attribute 'state'`. + +- [ ] **Step 3: Promote the enum** + + In `src/httpware/middleware/resilience/circuit_breaker.py`, rename the class `_CircuitState` to `CircuitState` and update EVERY reference (there are ~11: the class def plus `_CircuitState.CLOSED` / `.OPEN` / `.HALF_OPEN` usages in `admit`, `on_success`, `on_failure`, `_enter_open`, and `__init__`). Keep the `str` values unchanged: + + ```python + class CircuitState(enum.Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + ``` + + After editing, confirm zero stragglers: `grep -n "_CircuitState" src/httpware/middleware/resilience/circuit_breaker.py` must return nothing. + +- [ ] **Step 4: Add the `state` property to `_CircuitBreakerState` and both wrappers** + + On `_CircuitBreakerState` (the stored state lives in `self._state: CircuitState`), add: + + ```python + @property + def state(self) -> CircuitState: + """The circuit's current stored state (raw read; no lazy OPEN→HALF_OPEN transition).""" + return self._state + ``` + + On BOTH `AsyncCircuitBreaker` and `CircuitBreaker`, add (the wrapper holds the breaker-state object in `self._state`): + + ```python + @property + def state(self) -> CircuitState: + """Current circuit state — CLOSED, OPEN, or HALF_OPEN. Read-only, side-effect-free.""" + return self._state.state + ``` + + NOTE: in the wrappers, `self._state` is the `_CircuitBreakerState` instance, so `self._state.state` reads its new property. Do not take the lock for this read. + +- [ ] **Step 5: Export `CircuitState`** + + In `src/httpware/middleware/resilience/__init__.py`: add `CircuitState` to the `from httpware.middleware.resilience.circuit_breaker import ...` line (it currently imports `AsyncCircuitBreaker, CircuitBreaker`) and add `"CircuitState"` to `__all__` (keep alphabetical — between `"Bulkhead"`/`"CircuitBreaker"` ordering: it sorts after `CircuitBreaker`? No — `CircuitBreaker` < `CircuitState` alphabetically, so `"CircuitState"` goes right after `"CircuitBreaker"`). + + In `src/httpware/__init__.py`: add `CircuitState` to the `from httpware.middleware.resilience import (...)` block and add `"CircuitState"` to `__all__` (right after `"CircuitBreaker"`, before `"CircuitOpenError"` — confirm alphabetical: CircuitBreaker, CircuitOpenError, CircuitState → actually `CircuitOpenError` < `CircuitState` since 'O' < 'S'; place `"CircuitState"` AFTER `"CircuitOpenError"`). + +- [ ] **Step 6: Run the async + public-API tests** + + Run: `just test tests/test_circuit_breaker.py tests/test_public_api.py` + Expected: PASS (new state tests + export tests + all existing breaker/public-api tests). + +- [ ] **Step 7: Add the sync mirror tests** + + Read `tests/test_circuit_breaker_sync.py` for its `_Clock`/client helpers + imports, then append sync mirrors of the two Step-1 behavior tests (no `async`/`await`, `CircuitBreaker` + sync client helper, `from httpware import CircuitState`). Same assertions and structure. + + Run: `just test tests/test_circuit_breaker_sync.py` + Expected: PASS. (The `state` property on `CircuitBreaker` delegates to the shared `_CircuitBreakerState`, so no extra production code beyond Step 4.) + +- [ ] **Step 8: Full suite + lint** + + Run: `just test && just lint` + Expected: all green, 100% coverage, lint clean. (`ty` will confirm the enum rename left no dangling `_CircuitState` reference.) + +- [ ] **Step 9: Commit** + + ```bash + git add src/httpware/middleware/resilience/circuit_breaker.py src/httpware/middleware/resilience/__init__.py src/httpware/__init__.py tests/test_circuit_breaker.py tests/test_circuit_breaker_sync.py tests/test_public_api.py + git commit -m "feat(circuit-breaker): public CircuitState enum + read-only state property + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Docs + release notes (0.14.0) + +Version is **tag-driven** — do NOT edit `pyproject.toml` (the field stays `"0"`; the release workflow runs `uv version` from the `0.14.0` tag). + +**Files:** +- Modify: `architecture/resilience.md`, `docs/resilience.md` +- Create: `planning/releases/0.14.0.md` + +- [ ] **Step 1: Update architecture/resilience.md** + + In the `## CircuitBreaker + AsyncTimeout` section, add a sentence: both breakers expose a read-only `state` property returning a public `CircuitState` enum (`CLOSED`/`OPEN`/`HALF_OPEN`) for health checks and introspection. Note it is a raw read of the stored state — because the OPEN→HALF_OPEN transition is lazy (on the next request after `reset_timeout`), `state` reports `OPEN` until a request is actually admitted as the probe; it never triggers the transition. No frontmatter (living prose). + +- [ ] **Step 2: Update docs/resilience.md** + + Add a short subsection (after the rate-mode subsection, before Sharing) showing the property with a health-check framing: + + ```python + from httpware import CircuitState + from httpware.middleware.resilience import AsyncCircuitBreaker + + breaker = AsyncCircuitBreaker(failure_threshold=5) + # ... later, in a health/readiness handler: + if breaker.state is CircuitState.OPEN: + ... # report the dependency as degraded + ``` + + Explain it's read-only and reflects the stored state (with the lazy-transition caveat: `OPEN` persists until the next request after `reset_timeout`). Match the page's voice and the `from httpware.middleware.resilience import ...` import style used elsewhere on the page. + +- [ ] **Step 3: Write the release notes** + + Read `planning/releases/0.13.0.md` for voice/structure. Create `planning/releases/0.14.0.md`: minor, additive-only, no breaking changes. Cover: new public `CircuitState` enum + read-only `state` property on both `AsyncCircuitBreaker` and `CircuitBreaker`; raw stored-state semantics (lazy-transition caveat); use case (health/readiness checks, dashboards, tests). Note manual control (`force_open`/`force_closed`) remains deferred. Usage code block. End with `## Shipped via` line `PR #XX — read-only circuit-breaker state introspection.` (literal `#XX` placeholder; filled at PR time). American spelling, single spaces after periods. + +- [ ] **Step 4: Verify docs build + full gate** + + Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict` + Expected: clean (the Material 2.0 banner is unrelated). Then `rm -rf site`. + Run: `just test && just lint` — green, 100% coverage, clean. + +- [ ] **Step 5: Commit** + + ```bash + git add architecture/resilience.md docs/resilience.md planning/releases/0.14.0.md + git commit -m "docs(circuit-breaker): document state introspection; 0.14.0 release notes + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Ship bookkeeping (after merge) + +Per the planning convention: set this bundle's `design.md` + `plan.md` to +`status: shipped` with the PR number, fill the `## Shipped via` PR number in the +release notes, move `changes/active/2026-06-16.03-circuit-breaker-state/` to +`changes/archive/`, flip its Index line from Active to Archived, and update the +deferred CircuitBreaker entry — drop the read-only `state` half (now shipped), +leaving only manual control (`force_open`/`force_closed`). Release 0.14.0 by +creating the `0.14.0` GitHub release (tag-driven publish).