httpware 0.13.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.13.0 → httpware-0.14.0}/PKG-INFO +1 -1
- {httpware-0.13.0 → httpware-0.14.0}/pyproject.toml +1 -1
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/__init__.py +2 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/__init__.py +2 -1
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/circuit_breaker.py +34 -11
- {httpware-0.13.0 → httpware-0.14.0}/README.md +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/client.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/errors.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.13.0 → httpware-0.14.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.13.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
|
]
|
|
@@ -70,7 +70,9 @@ _ROLE_PROBE = "probe"
|
|
|
70
70
|
_LOGGER = logging.getLogger("httpware.circuit_breaker")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
class
|
|
73
|
+
class CircuitState(enum.Enum):
|
|
74
|
+
"""Lifecycle state of a circuit breaker: CLOSED, OPEN, or HALF_OPEN."""
|
|
75
|
+
|
|
74
76
|
CLOSED = "closed"
|
|
75
77
|
OPEN = "open"
|
|
76
78
|
HALF_OPEN = "half_open"
|
|
@@ -172,7 +174,7 @@ class _CircuitBreakerState:
|
|
|
172
174
|
self._window = _RollingWindow(window_seconds) if self._rate_mode else None
|
|
173
175
|
self._window_seconds = window_seconds
|
|
174
176
|
self._now = now
|
|
175
|
-
self._state =
|
|
177
|
+
self._state = CircuitState.CLOSED
|
|
176
178
|
self._consecutive_failures = 0
|
|
177
179
|
self._consecutive_successes = 0
|
|
178
180
|
self._opened_at = 0.0
|
|
@@ -181,14 +183,19 @@ class _CircuitBreakerState:
|
|
|
181
183
|
def is_failure_status(self, status_code: int) -> bool:
|
|
182
184
|
return status_code in self._failure_status_codes
|
|
183
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
|
+
|
|
184
191
|
def admit(self, request: httpx2.Request) -> str:
|
|
185
192
|
"""Decide the request's role, or raise CircuitOpenError. No await inside."""
|
|
186
|
-
if self._state is
|
|
193
|
+
if self._state is CircuitState.CLOSED:
|
|
187
194
|
return _ROLE_CLOSED
|
|
188
|
-
if self._state is
|
|
195
|
+
if self._state is CircuitState.OPEN:
|
|
189
196
|
elapsed = self._now() - self._opened_at
|
|
190
197
|
if elapsed >= self._reset_timeout:
|
|
191
|
-
self._state =
|
|
198
|
+
self._state = CircuitState.HALF_OPEN
|
|
192
199
|
self._probe_in_flight = True
|
|
193
200
|
self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
|
|
194
201
|
return _ROLE_PROBE
|
|
@@ -217,15 +224,15 @@ class _CircuitBreakerState:
|
|
|
217
224
|
def on_success(self, role: str, request: httpx2.Request) -> None:
|
|
218
225
|
if role == _ROLE_PROBE:
|
|
219
226
|
self._probe_in_flight = False
|
|
220
|
-
if self._state is
|
|
227
|
+
if self._state is CircuitState.CLOSED:
|
|
221
228
|
if self._rate_mode:
|
|
222
229
|
self._record_outcome(request, failed=False)
|
|
223
230
|
else:
|
|
224
231
|
self._consecutive_failures = 0
|
|
225
|
-
elif self._state is
|
|
232
|
+
elif self._state is CircuitState.HALF_OPEN:
|
|
226
233
|
self._consecutive_successes += 1
|
|
227
234
|
if self._consecutive_successes >= self._success_threshold:
|
|
228
|
-
self._state =
|
|
235
|
+
self._state = CircuitState.CLOSED
|
|
229
236
|
self._consecutive_failures = 0
|
|
230
237
|
self._consecutive_successes = 0
|
|
231
238
|
if self._rate_mode:
|
|
@@ -235,14 +242,14 @@ class _CircuitBreakerState:
|
|
|
235
242
|
def on_failure(self, role: str, request: httpx2.Request) -> None:
|
|
236
243
|
if role == _ROLE_PROBE:
|
|
237
244
|
self._probe_in_flight = False
|
|
238
|
-
if self._state is
|
|
245
|
+
if self._state is CircuitState.CLOSED:
|
|
239
246
|
if self._rate_mode:
|
|
240
247
|
self._record_outcome(request, failed=True)
|
|
241
248
|
else:
|
|
242
249
|
self._consecutive_failures += 1
|
|
243
250
|
if self._consecutive_failures >= self._failure_threshold:
|
|
244
251
|
self._open(request, failures=self._consecutive_failures)
|
|
245
|
-
elif self._state is
|
|
252
|
+
elif self._state is CircuitState.HALF_OPEN:
|
|
246
253
|
self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
|
|
247
254
|
|
|
248
255
|
def release_probe(self, role: str) -> None:
|
|
@@ -251,7 +258,7 @@ class _CircuitBreakerState:
|
|
|
251
258
|
self._probe_in_flight = False
|
|
252
259
|
|
|
253
260
|
def _enter_open(self, request: httpx2.Request, message: str, attributes: dict[str, typing.Any]) -> None:
|
|
254
|
-
self._state =
|
|
261
|
+
self._state = CircuitState.OPEN
|
|
255
262
|
self._opened_at = self._now()
|
|
256
263
|
self._consecutive_failures = 0
|
|
257
264
|
self._consecutive_successes = 0
|
|
@@ -346,6 +353,14 @@ class AsyncCircuitBreaker:
|
|
|
346
353
|
elif self._loop is not current: # pragma: no cover
|
|
347
354
|
raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
|
|
348
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
|
+
|
|
349
364
|
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
350
365
|
"""Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
|
|
351
366
|
self._check_loop()
|
|
@@ -399,6 +414,14 @@ class CircuitBreaker:
|
|
|
399
414
|
)
|
|
400
415
|
self._lock = threading.Lock()
|
|
401
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
|
+
|
|
402
425
|
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
|
|
403
426
|
"""Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
|
|
404
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
|