httpware 0.12.0__tar.gz → 0.13.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.13.0}/PKG-INFO +1 -1
  2. {httpware-0.12.0 → httpware-0.13.0}/pyproject.toml +1 -1
  3. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/circuit_breaker.py +132 -12
  4. {httpware-0.12.0 → httpware-0.13.0}/README.md +0 -0
  5. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/__init__.py +0 -0
  6. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/__init__.py +0 -0
  7. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/exception_mapping.py +0 -0
  8. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/import_checker.py +0 -0
  9. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/observability.py +0 -0
  10. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/redaction.py +0 -0
  11. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/status.py +0 -0
  12. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/client.py +0 -0
  13. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/__init__.py +0 -0
  14. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/msgspec.py +0 -0
  15. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/pydantic.py +0 -0
  16. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/errors.py +0 -0
  17. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/__init__.py +0 -0
  18. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/chain.py +0 -0
  19. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/__init__.py +0 -0
  20. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  21. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/budget.py +0 -0
  22. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  23. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/retry.py +0 -0
  24. {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  25. {httpware-0.12.0 → httpware-0.13.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.13.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.13.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -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,6 +62,8 @@ _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
 
@@ -62,6 +76,56 @@ class _CircuitState(enum.Enum):
62
76
  HALF_OPEN = "half_open"
63
77
 
64
78
 
79
+ class _RollingWindow:
80
+ """Time-bucketed success/failure counters over a rolling window.
81
+
82
+ `window_seconds` is split into `_BUCKET_COUNT` buckets. Each bucket holds
83
+ [successes, failures] tagged with the integer time-slot it represents; a
84
+ bucket whose slot is stale is reset on write, and `totals` filters to the
85
+ live slot range so data older than the window never counts. Every method is
86
+ synchronous and reads `now` from its caller (so the breaker's critical
87
+ section owns the clock read).
88
+ """
89
+
90
+ def __init__(self, window_seconds: float) -> None:
91
+ self._bucket_width = window_seconds / _BUCKET_COUNT
92
+ self._slot = [-1] * _BUCKET_COUNT
93
+ self._success = [0] * _BUCKET_COUNT
94
+ self._failure = [0] * _BUCKET_COUNT
95
+
96
+ def _current_slot(self, now: float) -> int:
97
+ return int(now // self._bucket_width)
98
+
99
+ def record(self, now: float, *, failed: bool) -> None:
100
+ slot = self._current_slot(now)
101
+ index = slot % _BUCKET_COUNT
102
+ if self._slot[index] != slot: # bucket reused for a new slot — evict
103
+ self._slot[index] = slot
104
+ self._success[index] = 0
105
+ self._failure[index] = 0
106
+ if failed:
107
+ self._failure[index] += 1
108
+ else:
109
+ self._success[index] += 1
110
+
111
+ def totals(self, now: float) -> tuple[int, int]:
112
+ """Return (total, failures) across buckets still inside the window at `now`."""
113
+ slot = self._current_slot(now)
114
+ oldest = slot - _BUCKET_COUNT + 1
115
+ total = 0
116
+ failures = 0
117
+ for i in range(_BUCKET_COUNT):
118
+ if oldest <= self._slot[i] <= slot:
119
+ total += self._success[i] + self._failure[i]
120
+ failures += self._failure[i]
121
+ return total, failures
122
+
123
+ def clear(self) -> None:
124
+ self._slot = [-1] * _BUCKET_COUNT
125
+ self._success = [0] * _BUCKET_COUNT
126
+ self._failure = [0] * _BUCKET_COUNT
127
+
128
+
65
129
  class _CircuitBreakerState:
66
130
  """Lock-free circuit-breaker state machine shared by the sync + async wrappers.
67
131
 
@@ -70,13 +134,16 @@ class _CircuitBreakerState:
70
134
  inside a transition); the sync wrapper wraps each call in a threading.Lock.
71
135
  """
72
136
 
73
- def __init__(
137
+ def __init__( # noqa: PLR0913 — breaker state has many orthogonal knobs; a dataclass would be worse
74
138
  self,
75
139
  *,
76
140
  failure_threshold: int,
77
141
  reset_timeout: float,
78
142
  success_threshold: int,
79
143
  failure_status_codes: Collection[int] | None,
144
+ failure_rate_threshold: float | None,
145
+ window_seconds: float,
146
+ minimum_calls: int,
80
147
  now: Callable[[], float],
81
148
  ) -> None:
82
149
  if failure_threshold < 1:
@@ -85,6 +152,12 @@ class _CircuitBreakerState:
85
152
  raise ValueError(_RESET_TIMEOUT_INVALID)
86
153
  if success_threshold < 1:
87
154
  raise ValueError(_SUCCESS_THRESHOLD_INVALID)
155
+ if failure_rate_threshold is not None and not (0.0 < failure_rate_threshold <= 1.0):
156
+ raise ValueError(_FAILURE_RATE_THRESHOLD_INVALID)
157
+ if window_seconds <= 0:
158
+ raise ValueError(_WINDOW_SECONDS_INVALID)
159
+ if minimum_calls < 1:
160
+ raise ValueError(_MINIMUM_CALLS_INVALID)
88
161
  self._failure_threshold = failure_threshold
89
162
  self._reset_timeout = reset_timeout
90
163
  self._success_threshold = success_threshold
@@ -93,6 +166,11 @@ class _CircuitBreakerState:
93
166
  self._failure_status_codes = (
94
167
  frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
95
168
  )
169
+ self._failure_rate_threshold = failure_rate_threshold
170
+ self._minimum_calls = minimum_calls
171
+ self._rate_mode = failure_rate_threshold is not None
172
+ self._window = _RollingWindow(window_seconds) if self._rate_mode else None
173
+ self._window_seconds = window_seconds
96
174
  self._now = now
97
175
  self._state = _CircuitState.CLOSED
98
176
  self._consecutive_failures = 0
@@ -140,22 +218,30 @@ class _CircuitBreakerState:
140
218
  if role == _ROLE_PROBE:
141
219
  self._probe_in_flight = False
142
220
  if self._state is _CircuitState.CLOSED:
143
- self._consecutive_failures = 0
221
+ if self._rate_mode:
222
+ self._record_outcome(request, failed=False)
223
+ else:
224
+ self._consecutive_failures = 0
144
225
  elif self._state is _CircuitState.HALF_OPEN:
145
226
  self._consecutive_successes += 1
146
227
  if self._consecutive_successes >= self._success_threshold:
147
228
  self._state = _CircuitState.CLOSED
148
229
  self._consecutive_failures = 0
149
230
  self._consecutive_successes = 0
231
+ if self._rate_mode:
232
+ self._window.clear() # ty: ignore[unresolved-attribute]
150
233
  self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
151
234
 
152
235
  def on_failure(self, role: str, request: httpx2.Request) -> None:
153
236
  if role == _ROLE_PROBE:
154
237
  self._probe_in_flight = False
155
238
  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)
239
+ if self._rate_mode:
240
+ self._record_outcome(request, failed=True)
241
+ else:
242
+ self._consecutive_failures += 1
243
+ if self._consecutive_failures >= self._failure_threshold:
244
+ self._open(request, failures=self._consecutive_failures)
159
245
  elif self._state is _CircuitState.HALF_OPEN:
160
246
  self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
161
247
 
@@ -164,19 +250,41 @@ class _CircuitBreakerState:
164
250
  if role == _ROLE_PROBE:
165
251
  self._probe_in_flight = False
166
252
 
167
- def _open(self, request: httpx2.Request, *, failures: int) -> None:
253
+ def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None:
168
254
  self._state = _CircuitState.OPEN
169
255
  self._opened_at = self._now()
170
256
  self._consecutive_failures = 0
171
257
  self._consecutive_successes = 0
172
- self._emit(
258
+ self._emit(request, "circuit.opened", logging.WARNING, message, attributes)
259
+
260
+ def _open(self, request: httpx2.Request, *, failures: int) -> None:
261
+ self._enter_open(
173
262
  request,
174
- "circuit.opened",
175
- logging.WARNING,
176
263
  "circuit opened — failure threshold reached",
177
264
  {"failure_threshold": self._failure_threshold, "failures": failures},
178
265
  )
179
266
 
267
+ def _open_rate(self, request: httpx2.Request, *, total: int, failures: int) -> None:
268
+ self._enter_open(
269
+ request,
270
+ "circuit opened — failure rate threshold reached",
271
+ {
272
+ "failure_rate": failures / total,
273
+ "failure_rate_threshold": self._failure_rate_threshold,
274
+ "window_seconds": self._window_seconds,
275
+ "observed_calls": total,
276
+ },
277
+ )
278
+
279
+ def _record_outcome(self, request: httpx2.Request, *, failed: bool) -> None:
280
+ # Only reached in rate mode, where _window and _failure_rate_threshold are non-None.
281
+ now = self._now()
282
+ self._window.record(now, failed=failed) # ty: ignore[unresolved-attribute]
283
+ total, failures = self._window.totals(now) # ty: ignore[unresolved-attribute]
284
+ threshold = self._failure_rate_threshold
285
+ if threshold is not None and total >= self._minimum_calls and failures / total >= threshold:
286
+ self._open_rate(request, total=total, failures=failures)
287
+
180
288
  def _emit(
181
289
  self,
182
290
  request: httpx2.Request,
@@ -197,13 +305,16 @@ class _CircuitBreakerState:
197
305
  class AsyncCircuitBreaker:
198
306
  """Async classic circuit breaker middleware. See the module docstring for the contract."""
199
307
 
200
- def __init__(
308
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
201
309
  self,
202
310
  *,
203
311
  failure_threshold: int = 5,
204
312
  reset_timeout: float = 30.0,
205
313
  success_threshold: int = 1,
206
314
  failure_status_codes: Collection[int] | None = None,
315
+ failure_rate_threshold: float | None = None,
316
+ window_seconds: float = 30.0,
317
+ minimum_calls: int = 20,
207
318
  _now: Callable[[], float] = time.monotonic,
208
319
  ) -> None:
209
320
  self._state = _CircuitBreakerState(
@@ -211,6 +322,9 @@ class AsyncCircuitBreaker:
211
322
  reset_timeout=reset_timeout,
212
323
  success_threshold=success_threshold,
213
324
  failure_status_codes=failure_status_codes,
325
+ failure_rate_threshold=failure_rate_threshold,
326
+ window_seconds=window_seconds,
327
+ minimum_calls=minimum_calls,
214
328
  now=_now,
215
329
  )
216
330
  self._loop: asyncio.AbstractEventLoop | None = None
@@ -261,13 +375,16 @@ class CircuitBreaker:
261
375
  (one shared circuit); a sync instance cannot be shared with an AsyncClient.
262
376
  """
263
377
 
264
- def __init__(
378
+ def __init__( # noqa: PLR0913 — breaker has many orthogonal knobs; a dataclass would be worse
265
379
  self,
266
380
  *,
267
381
  failure_threshold: int = 5,
268
382
  reset_timeout: float = 30.0,
269
383
  success_threshold: int = 1,
270
384
  failure_status_codes: Collection[int] | None = None,
385
+ failure_rate_threshold: float | None = None,
386
+ window_seconds: float = 30.0,
387
+ minimum_calls: int = 20,
271
388
  _now: Callable[[], float] = time.monotonic,
272
389
  ) -> None:
273
390
  self._state = _CircuitBreakerState(
@@ -275,6 +392,9 @@ class CircuitBreaker:
275
392
  reset_timeout=reset_timeout,
276
393
  success_threshold=success_threshold,
277
394
  failure_status_codes=failure_status_codes,
395
+ failure_rate_threshold=failure_rate_threshold,
396
+ window_seconds=window_seconds,
397
+ minimum_calls=minimum_calls,
278
398
  now=_now,
279
399
  )
280
400
  self._lock = threading.Lock()
File without changes