Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions architecture/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
117 changes: 117 additions & 0 deletions planning/changes/active/2026-06-16.03-circuit-breaker-state/design.md
Original file line number Diff line number Diff line change
@@ -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.
Loading