httpware 0.12.0__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {httpware-0.12.0 → httpware-0.14.0}/PKG-INFO +1 -1
  2. {httpware-0.12.0 → httpware-0.14.0}/pyproject.toml +1 -1
  3. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/__init__.py +2 -0
  4. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/__init__.py +2 -1
  5. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/circuit_breaker.py +166 -23
  6. {httpware-0.12.0 → httpware-0.14.0}/README.md +0 -0
  7. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/__init__.py +0 -0
  8. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/exception_mapping.py +0 -0
  9. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/import_checker.py +0 -0
  10. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/observability.py +0 -0
  11. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/redaction.py +0 -0
  12. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/status.py +0 -0
  13. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/client.py +0 -0
  14. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/__init__.py +0 -0
  15. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/msgspec.py +0 -0
  16. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/pydantic.py +0 -0
  17. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/errors.py +0 -0
  18. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/__init__.py +0 -0
  19. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/chain.py +0 -0
  20. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  21. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/budget.py +0 -0
  22. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  23. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/retry.py +0 -0
  24. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  25. {httpware-0.12.0 → httpware-0.14.0}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: Resilience-first async HTTP client framework for Python
5
5
  Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
6
6
  Author: Artur Shiriev
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.12.0"
29
+ version = "0.14.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -46,6 +46,7 @@ from httpware.middleware.resilience import (
46
46
  AsyncTimeout,
47
47
  Bulkhead,
48
48
  CircuitBreaker,
49
+ CircuitState,
49
50
  Retry,
50
51
  RetryBudget,
51
52
  )
@@ -65,6 +66,7 @@ __all__ = [
65
66
  "BulkheadFullError",
66
67
  "CircuitBreaker",
67
68
  "CircuitOpenError",
69
+ "CircuitState",
68
70
  "Client",
69
71
  "ClientError",
70
72
  "ClientStatusError",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from httpware.middleware.resilience.budget import RetryBudget
4
4
  from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
5
- from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker
5
+ from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker, CircuitState
6
6
  from httpware.middleware.resilience.retry import AsyncRetry, Retry
7
7
  from httpware.middleware.resilience.timeout import AsyncTimeout
8
8
 
@@ -14,6 +14,7 @@ __all__ = [
14
14
  "AsyncTimeout",
15
15
  "Bulkhead",
16
16
  "CircuitBreaker",
17
+ "CircuitState",
17
18
  "Retry",
18
19
  "RetryBudget",
19
20
  ]
@@ -1,4 +1,4 @@
1
- """CircuitBreaker + AsyncCircuitBreaker — classic consecutive-failure circuit breaker.
1
+ """CircuitBreaker + AsyncCircuitBreaker — consecutive-failure and failure-rate circuit breakers.
2
2
 
3
3
  See planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md for the contract.
4
4
 
@@ -17,6 +17,15 @@ State machine (classic / consecutive-failure):
17
17
  HALF_OPEN — admit exactly one probe at a time; success_threshold consecutive probe
18
18
  successes close the circuit; one probe failure re-opens it.
19
19
 
20
+ Trip modes:
21
+ Classic (default) — opens when consecutive counted-failures reach failure_threshold.
22
+ Set failure_threshold to use this mode; leave failure_rate_threshold unset.
23
+ Rate (opt-in) — opens when the failure rate over a rolling window_seconds window
24
+ meets or exceeds failure_rate_threshold, provided at least minimum_calls
25
+ outcomes have been observed in that window. Set failure_rate_threshold to
26
+ activate; failure_threshold is ignored in this mode.
27
+ Half-open recovery and event names are identical across both modes.
28
+
20
29
  The lock-free _CircuitBreakerState holds the transition logic, shared by both wrappers.
21
30
  AsyncCircuitBreaker relies on asyncio atomicity (no await inside a transition) plus a
22
31
  single-event-loop guard; CircuitBreaker (sync) serializes transitions with a
@@ -42,6 +51,9 @@ from httpware.middleware import AsyncNext, Next
42
51
  _FAILURE_THRESHOLD_INVALID = "failure_threshold must be >= 1"
43
52
  _RESET_TIMEOUT_INVALID = "reset_timeout must be >= 0"
44
53
  _SUCCESS_THRESHOLD_INVALID = "success_threshold must be >= 1"
54
+ _FAILURE_RATE_THRESHOLD_INVALID = "failure_rate_threshold must be in (0, 1]"
55
+ _WINDOW_SECONDS_INVALID = "window_seconds must be > 0"
56
+ _MINIMUM_CALLS_INVALID = "minimum_calls must be >= 1"
45
57
  _CROSS_LOOP_MSG = (
46
58
  "AsyncCircuitBreaker is bound to a single event loop. First seen on {first!r}; "
47
59
  "current request is on {current!r}. Use one AsyncCircuitBreaker per loop; "
@@ -50,18 +62,72 @@ _CROSS_LOOP_MSG = (
50
62
 
51
63
  _DEFAULT_FAILURE_STATUS_CODES = frozenset(range(500, 600))
52
64
 
65
+ _BUCKET_COUNT = 10
66
+
53
67
  _ROLE_CLOSED = "closed"
54
68
  _ROLE_PROBE = "probe"
55
69
 
56
70
  _LOGGER = logging.getLogger("httpware.circuit_breaker")
57
71
 
58
72
 
59
- class _CircuitState(enum.Enum):
73
+ class CircuitState(enum.Enum):
74
+ """Lifecycle state of a circuit breaker: CLOSED, OPEN, or HALF_OPEN."""
75
+
60
76
  CLOSED = "closed"
61
77
  OPEN = "open"
62
78
  HALF_OPEN = "half_open"
63
79
 
64
80
 
81
+ class _RollingWindow:
82
+ """Time-bucketed success/failure counters over a rolling window.
83
+
84
+ `window_seconds` is split into `_BUCKET_COUNT` buckets. Each bucket holds
85
+ [successes, failures] tagged with the integer time-slot it represents; a
86
+ bucket whose slot is stale is reset on write, and `totals` filters to the
87
+ live slot range so data older than the window never counts. Every method is
88
+ synchronous and reads `now` from its caller (so the breaker's critical
89
+ section owns the clock read).
90
+ """
91
+
92
+ def __init__(self, window_seconds: float) -> None:
93
+ self._bucket_width = window_seconds / _BUCKET_COUNT
94
+ self._slot = [-1] * _BUCKET_COUNT
95
+ self._success = [0] * _BUCKET_COUNT
96
+ self._failure = [0] * _BUCKET_COUNT
97
+
98
+ def _current_slot(self, now: float) -> int:
99
+ return int(now // self._bucket_width)
100
+
101
+ def record(self, now: float, *, failed: bool) -> None:
102
+ slot = self._current_slot(now)
103
+ index = slot % _BUCKET_COUNT
104
+ if self._slot[index] != slot: # bucket reused for a new slot — evict
105
+ self._slot[index] = slot
106
+ self._success[index] = 0
107
+ self._failure[index] = 0
108
+ if failed:
109
+ self._failure[index] += 1
110
+ else:
111
+ self._success[index] += 1
112
+
113
+ def totals(self, now: float) -> tuple[int, int]:
114
+ """Return (total, failures) across buckets still inside the window at `now`."""
115
+ slot = self._current_slot(now)
116
+ oldest = slot - _BUCKET_COUNT + 1
117
+ total = 0
118
+ failures = 0
119
+ for i in range(_BUCKET_COUNT):
120
+ if oldest <= self._slot[i] <= slot:
121
+ total += self._success[i] + self._failure[i]
122
+ failures += self._failure[i]
123
+ return total, failures
124
+
125
+ def clear(self) -> None:
126
+ self._slot = [-1] * _BUCKET_COUNT
127
+ self._success = [0] * _BUCKET_COUNT
128
+ self._failure = [0] * _BUCKET_COUNT
129
+
130
+
65
131
  class _CircuitBreakerState:
66
132
  """Lock-free circuit-breaker state machine shared by the sync + async wrappers.
67
133
 
@@ -70,13 +136,16 @@ class _CircuitBreakerState:
70
136
  inside a transition); the sync wrapper wraps each call in a threading.Lock.
71
137
  """
72
138
 
73
- def __init__(
139
+ def __init__( # noqa: PLR0913 — breaker state has many orthogonal knobs; a dataclass would be worse
74
140
  self,
75
141
  *,
76
142
  failure_threshold: int,
77
143
  reset_timeout: float,
78
144
  success_threshold: int,
79
145
  failure_status_codes: Collection[int] | None,
146
+ failure_rate_threshold: float | None,
147
+ window_seconds: float,
148
+ minimum_calls: int,
80
149
  now: Callable[[], float],
81
150
  ) -> None:
82
151
  if failure_threshold < 1:
@@ -85,6 +154,12 @@ class _CircuitBreakerState:
85
154
  raise ValueError(_RESET_TIMEOUT_INVALID)
86
155
  if success_threshold < 1:
87
156
  raise ValueError(_SUCCESS_THRESHOLD_INVALID)
157
+ if failure_rate_threshold is not None and not (0.0 < failure_rate_threshold <= 1.0):
158
+ raise ValueError(_FAILURE_RATE_THRESHOLD_INVALID)
159
+ if window_seconds <= 0:
160
+ raise ValueError(_WINDOW_SECONDS_INVALID)
161
+ if minimum_calls < 1:
162
+ raise ValueError(_MINIMUM_CALLS_INVALID)
88
163
  self._failure_threshold = failure_threshold
89
164
  self._reset_timeout = reset_timeout
90
165
  self._success_threshold = success_threshold
@@ -93,8 +168,13 @@ class _CircuitBreakerState:
93
168
  self._failure_status_codes = (
94
169
  frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
95
170
  )
171
+ self._failure_rate_threshold = failure_rate_threshold
172
+ self._minimum_calls = minimum_calls
173
+ self._rate_mode = failure_rate_threshold is not None
174
+ self._window = _RollingWindow(window_seconds) if self._rate_mode else None
175
+ self._window_seconds = window_seconds
96
176
  self._now = now
97
- self._state = _CircuitState.CLOSED
177
+ self._state = CircuitState.CLOSED
98
178
  self._consecutive_failures = 0
99
179
  self._consecutive_successes = 0
100
180
  self._opened_at = 0.0
@@ -103,14 +183,19 @@ class _CircuitBreakerState:
103
183
  def is_failure_status(self, status_code: int) -> bool:
104
184
  return status_code in self._failure_status_codes
105
185
 
186
+ @property
187
+ def state(self) -> CircuitState:
188
+ """The circuit's current stored state (raw read; no lazy OPEN→HALF_OPEN transition)."""
189
+ return self._state
190
+
106
191
  def admit(self, request: httpx2.Request) -> str:
107
192
  """Decide the request's role, or raise CircuitOpenError. No await inside."""
108
- if self._state is _CircuitState.CLOSED:
193
+ if self._state is CircuitState.CLOSED:
109
194
  return _ROLE_CLOSED
110
- if self._state is _CircuitState.OPEN:
195
+ if self._state is CircuitState.OPEN:
111
196
  elapsed = self._now() - self._opened_at
112
197
  if elapsed >= self._reset_timeout:
113
- self._state = _CircuitState.HALF_OPEN
198
+ self._state = CircuitState.HALF_OPEN
114
199
  self._probe_in_flight = True
115
200
  self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
116
201
  return _ROLE_PROBE
@@ -139,24 +224,32 @@ class _CircuitBreakerState:
139
224
  def on_success(self, role: str, request: httpx2.Request) -> None:
140
225
  if role == _ROLE_PROBE:
141
226
  self._probe_in_flight = False
142
- if self._state is _CircuitState.CLOSED:
143
- self._consecutive_failures = 0
144
- elif self._state is _CircuitState.HALF_OPEN:
227
+ if self._state is CircuitState.CLOSED:
228
+ if self._rate_mode:
229
+ self._record_outcome(request, failed=False)
230
+ else:
231
+ self._consecutive_failures = 0
232
+ elif self._state is CircuitState.HALF_OPEN:
145
233
  self._consecutive_successes += 1
146
234
  if self._consecutive_successes >= self._success_threshold:
147
- self._state = _CircuitState.CLOSED
235
+ self._state = CircuitState.CLOSED
148
236
  self._consecutive_failures = 0
149
237
  self._consecutive_successes = 0
238
+ if self._rate_mode:
239
+ self._window.clear() # ty: ignore[unresolved-attribute]
150
240
  self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
151
241
 
152
242
  def on_failure(self, role: str, request: httpx2.Request) -> None:
153
243
  if role == _ROLE_PROBE:
154
244
  self._probe_in_flight = False
155
- if self._state is _CircuitState.CLOSED:
156
- self._consecutive_failures += 1
157
- if self._consecutive_failures >= self._failure_threshold:
158
- self._open(request, failures=self._consecutive_failures)
159
- elif self._state is _CircuitState.HALF_OPEN:
245
+ if self._state is CircuitState.CLOSED:
246
+ if self._rate_mode:
247
+ self._record_outcome(request, failed=True)
248
+ else:
249
+ self._consecutive_failures += 1
250
+ if self._consecutive_failures >= self._failure_threshold:
251
+ self._open(request, failures=self._consecutive_failures)
252
+ elif self._state is CircuitState.HALF_OPEN:
160
253
  self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
161
254
 
162
255
  def release_probe(self, role: str) -> None:
@@ -164,19 +257,41 @@ class _CircuitBreakerState:
164
257
  if role == _ROLE_PROBE:
165
258
  self._probe_in_flight = False
166
259
 
167
- def _open(self, request: httpx2.Request, *, failures: int) -> None:
168
- self._state = _CircuitState.OPEN
260
+ def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None:
261
+ self._state = CircuitState.OPEN
169
262
  self._opened_at = self._now()
170
263
  self._consecutive_failures = 0
171
264
  self._consecutive_successes = 0
172
- self._emit(
265
+ self._emit(request, "circuit.opened", logging.WARNING, message, attributes)
266
+
267
+ def _open(self, request: httpx2.Request, *, failures: int) -> None:
268
+ self._enter_open(
173
269
  request,
174
- "circuit.opened",
175
- logging.WARNING,
176
270
  "circuit opened — failure threshold reached",
177
271
  {"failure_threshold": self._failure_threshold, "failures": failures},
178
272
  )
179
273
 
274
+ def _open_rate(self, request: httpx2.Request, *, total: int, failures: int) -> None:
275
+ self._enter_open(
276
+ request,
277
+ "circuit opened — failure rate threshold reached",
278
+ {
279
+ "failure_rate": failures / total,
280
+ "failure_rate_threshold": self._failure_rate_threshold,
281
+ "window_seconds": self._window_seconds,
282
+ "observed_calls": total,
283
+ },
284
+ )
285
+
286
+ def _record_outcome(self, request: httpx2.Request, *, failed: bool) -> None:
287
+ # Only reached in rate mode, where _window and _failure_rate_threshold are non-None.
288
+ now = self._now()
289
+ self._window.record(now, failed=failed) # ty: ignore[unresolved-attribute]
290
+ total, failures = self._window.totals(now) # ty: ignore[unresolved-attribute]
291
+ threshold = self._failure_rate_threshold
292
+ if threshold is not None and total >= self._minimum_calls and failures / total >= threshold:
293
+ self._open_rate(request, total=total, failures=failures)
294
+
180
295
  def _emit(
181
296
  self,
182
297
  request: httpx2.Request,
@@ -197,13 +312,16 @@ class _CircuitBreakerState:
197
312
  class AsyncCircuitBreaker:
198
313
  """Async classic circuit breaker middleware. See the module docstring for the contract."""
199
314
 
200
- def __init__(
315
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
201
316
  self,
202
317
  *,
203
318
  failure_threshold: int = 5,
204
319
  reset_timeout: float = 30.0,
205
320
  success_threshold: int = 1,
206
321
  failure_status_codes: Collection[int] | None = None,
322
+ failure_rate_threshold: float | None = None,
323
+ window_seconds: float = 30.0,
324
+ minimum_calls: int = 20,
207
325
  _now: Callable[[], float] = time.monotonic,
208
326
  ) -> None:
209
327
  self._state = _CircuitBreakerState(
@@ -211,6 +329,9 @@ class AsyncCircuitBreaker:
211
329
  reset_timeout=reset_timeout,
212
330
  success_threshold=success_threshold,
213
331
  failure_status_codes=failure_status_codes,
332
+ failure_rate_threshold=failure_rate_threshold,
333
+ window_seconds=window_seconds,
334
+ minimum_calls=minimum_calls,
214
335
  now=_now,
215
336
  )
216
337
  self._loop: asyncio.AbstractEventLoop | None = None
@@ -232,6 +353,14 @@ class AsyncCircuitBreaker:
232
353
  elif self._loop is not current: # pragma: no cover
233
354
  raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
234
355
 
356
+ @property
357
+ def state(self) -> CircuitState:
358
+ """Current circuit state — CLOSED, OPEN, or HALF_OPEN.
359
+
360
+ Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free).
361
+ """
362
+ return self._state.state
363
+
235
364
  async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
236
365
  """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
237
366
  self._check_loop()
@@ -261,13 +390,16 @@ class CircuitBreaker:
261
390
  (one shared circuit); a sync instance cannot be shared with an AsyncClient.
262
391
  """
263
392
 
264
- def __init__(
393
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
265
394
  self,
266
395
  *,
267
396
  failure_threshold: int = 5,
268
397
  reset_timeout: float = 30.0,
269
398
  success_threshold: int = 1,
270
399
  failure_status_codes: Collection[int] | None = None,
400
+ failure_rate_threshold: float | None = None,
401
+ window_seconds: float = 30.0,
402
+ minimum_calls: int = 20,
271
403
  _now: Callable[[], float] = time.monotonic,
272
404
  ) -> None:
273
405
  self._state = _CircuitBreakerState(
@@ -275,10 +407,21 @@ class CircuitBreaker:
275
407
  reset_timeout=reset_timeout,
276
408
  success_threshold=success_threshold,
277
409
  failure_status_codes=failure_status_codes,
410
+ failure_rate_threshold=failure_rate_threshold,
411
+ window_seconds=window_seconds,
412
+ minimum_calls=minimum_calls,
278
413
  now=_now,
279
414
  )
280
415
  self._lock = threading.Lock()
281
416
 
417
+ @property
418
+ def state(self) -> CircuitState:
419
+ """Current circuit state — CLOSED, OPEN, or HALF_OPEN.
420
+
421
+ Read-only and side-effect-free (a single atomic attribute read; intentionally lock-free).
422
+ """
423
+ return self._state.state
424
+
282
425
  def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
283
426
  """Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
284
427
  with self._lock:
File without changes