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.
- {httpware-0.12.0 → httpware-0.13.0}/PKG-INFO +1 -1
- {httpware-0.12.0 → httpware-0.13.0}/pyproject.toml +1 -1
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/circuit_breaker.py +132 -12
- {httpware-0.12.0 → httpware-0.13.0}/README.md +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/client.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/errors.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.12.0 → httpware-0.13.0}/src/httpware/py.typed +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CircuitBreaker + AsyncCircuitBreaker —
|
|
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.
|
|
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.
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|