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.
- {httpware-0.12.0 → httpware-0.14.0}/PKG-INFO +1 -1
- {httpware-0.12.0 → httpware-0.14.0}/pyproject.toml +1 -1
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/__init__.py +2 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/__init__.py +2 -1
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/circuit_breaker.py +166 -23
- {httpware-0.12.0 → httpware-0.14.0}/README.md +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/client.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/errors.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.12.0 → httpware-0.14.0}/src/httpware/py.typed +0 -0
|
@@ -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 —
|
|
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
|
|
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 =
|
|
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
|
|
193
|
+
if self._state is CircuitState.CLOSED:
|
|
109
194
|
return _ROLE_CLOSED
|
|
110
|
-
if self._state is
|
|
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 =
|
|
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
|
|
143
|
-
self.
|
|
144
|
-
|
|
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 =
|
|
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
|
|
156
|
-
self.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
168
|
-
self._state =
|
|
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
|
|
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
|