httpware 0.9.1__tar.gz → 0.10.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.9.1 → httpware-0.10.0}/PKG-INFO +7 -5
- {httpware-0.9.1 → httpware-0.10.0}/README.md +6 -4
- {httpware-0.9.1 → httpware-0.10.0}/pyproject.toml +1 -1
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/__init__.py +15 -1
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/errors.py +29 -0
- httpware-0.10.0/src/httpware/middleware/resilience/__init__.py +19 -0
- httpware-0.10.0/src/httpware/middleware/resilience/circuit_breaker.py +302 -0
- httpware-0.10.0/src/httpware/middleware/resilience/timeout.py +75 -0
- httpware-0.9.1/src/httpware/middleware/resilience/__init__.py +0 -8
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/client.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.9.1 → httpware-0.10.0}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
|
|
38
38
|
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
39
39
|
|
|
40
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a
|
|
40
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a Finagle-style `RetryBudget`, `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds.
|
|
41
41
|
|
|
42
42
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
43
43
|
|
|
@@ -146,16 +146,18 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
146
146
|
|
|
147
147
|
## Observability
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
All resilience middleware emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
|
|
150
150
|
|
|
151
|
-
Logger names
|
|
151
|
+
Logger names and event names are the stable public contract: `httpware.retry` (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`), `httpware.bulkhead` (`bulkhead.rejected`), `httpware.circuit_breaker` (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`), and `httpware.timeout` (`timeout.exceeded`).
|
|
152
152
|
|
|
153
153
|
```python
|
|
154
154
|
import logging
|
|
155
155
|
|
|
156
|
-
# Enable visibility into
|
|
156
|
+
# Enable visibility into resilience operational events
|
|
157
157
|
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
158
158
|
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
159
|
+
logging.getLogger("httpware.circuit_breaker").setLevel(logging.WARNING)
|
|
160
|
+
logging.getLogger("httpware.timeout").setLevel(logging.WARNING)
|
|
159
161
|
```
|
|
160
162
|
|
|
161
163
|
For OTel attribute enrichment on the active span — install the extra:
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
9
9
|
|
|
10
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a
|
|
10
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a Finagle-style `RetryBudget`, `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds.
|
|
11
11
|
|
|
12
12
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
13
13
|
|
|
@@ -116,16 +116,18 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
116
116
|
|
|
117
117
|
## Observability
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
All resilience middleware emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
|
|
120
120
|
|
|
121
|
-
Logger names
|
|
121
|
+
Logger names and event names are the stable public contract: `httpware.retry` (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`), `httpware.bulkhead` (`bulkhead.rejected`), `httpware.circuit_breaker` (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`), and `httpware.timeout` (`timeout.exceeded`).
|
|
122
122
|
|
|
123
123
|
```python
|
|
124
124
|
import logging
|
|
125
125
|
|
|
126
|
-
# Enable visibility into
|
|
126
|
+
# Enable visibility into resilience operational events
|
|
127
127
|
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
128
128
|
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
129
|
+
logging.getLogger("httpware.circuit_breaker").setLevel(logging.WARNING)
|
|
130
|
+
logging.getLogger("httpware.timeout").setLevel(logging.WARNING)
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
For OTel attribute enrichment on the active span — install the extra:
|
|
@@ -6,6 +6,7 @@ from httpware.errors import (
|
|
|
6
6
|
STATUS_TO_EXCEPTION,
|
|
7
7
|
BadRequestError,
|
|
8
8
|
BulkheadFullError,
|
|
9
|
+
CircuitOpenError,
|
|
9
10
|
ClientError,
|
|
10
11
|
ClientStatusError,
|
|
11
12
|
ConflictError,
|
|
@@ -37,19 +38,32 @@ from httpware.middleware import (
|
|
|
37
38
|
before_request,
|
|
38
39
|
on_error,
|
|
39
40
|
)
|
|
40
|
-
from httpware.middleware.resilience import
|
|
41
|
+
from httpware.middleware.resilience import (
|
|
42
|
+
AsyncBulkhead,
|
|
43
|
+
AsyncCircuitBreaker,
|
|
44
|
+
AsyncRetry,
|
|
45
|
+
AsyncTimeout,
|
|
46
|
+
Bulkhead,
|
|
47
|
+
CircuitBreaker,
|
|
48
|
+
Retry,
|
|
49
|
+
RetryBudget,
|
|
50
|
+
)
|
|
41
51
|
|
|
42
52
|
|
|
43
53
|
__all__ = [
|
|
44
54
|
"STATUS_TO_EXCEPTION",
|
|
45
55
|
"AsyncBulkhead",
|
|
56
|
+
"AsyncCircuitBreaker",
|
|
46
57
|
"AsyncClient",
|
|
47
58
|
"AsyncMiddleware",
|
|
48
59
|
"AsyncNext",
|
|
49
60
|
"AsyncRetry",
|
|
61
|
+
"AsyncTimeout",
|
|
50
62
|
"BadRequestError",
|
|
51
63
|
"Bulkhead",
|
|
52
64
|
"BulkheadFullError",
|
|
65
|
+
"CircuitBreaker",
|
|
66
|
+
"CircuitOpenError",
|
|
53
67
|
"Client",
|
|
54
68
|
"ClientError",
|
|
55
69
|
"ClientStatusError",
|
|
@@ -214,6 +214,35 @@ class BulkheadFullError(ClientError):
|
|
|
214
214
|
)
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
def _reconstruct_circuit_open(
|
|
218
|
+
cls: "type[CircuitOpenError]",
|
|
219
|
+
retry_after: float | None,
|
|
220
|
+
) -> "CircuitOpenError":
|
|
221
|
+
return cls(retry_after=retry_after)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class CircuitOpenError(ClientError):
|
|
225
|
+
"""Raised when a CircuitBreaker refuses a request because the circuit is not closed.
|
|
226
|
+
|
|
227
|
+
Fires when the circuit is OPEN, or when it is HALF_OPEN and the single probe
|
|
228
|
+
slot is already taken. The request is never forwarded to ``next``. ``retry_after``
|
|
229
|
+
carries the seconds until the circuit will next admit a probe, when known
|
|
230
|
+
(``None`` when a concurrent probe is already in flight).
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
retry_after: float | None
|
|
234
|
+
|
|
235
|
+
def __init__(self, *, retry_after: float | None) -> None:
|
|
236
|
+
self.retry_after = retry_after
|
|
237
|
+
if retry_after is None:
|
|
238
|
+
super().__init__("circuit open (a probe request is already in flight)")
|
|
239
|
+
else:
|
|
240
|
+
super().__init__(f"circuit open (retry_after={retry_after:.3f}s)")
|
|
241
|
+
|
|
242
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
243
|
+
return (_reconstruct_circuit_open, (type(self), self.retry_after))
|
|
244
|
+
|
|
245
|
+
|
|
217
246
|
def _reconstruct_decode_error(
|
|
218
247
|
cls: "type[DecodeError]",
|
|
219
248
|
response: httpx2.Response,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Resilience middleware: Bulkhead, CircuitBreaker, Retry, RetryBudget, and their Async counterparts + AsyncTimeout."""
|
|
2
|
+
|
|
3
|
+
from httpware.middleware.resilience.budget import RetryBudget
|
|
4
|
+
from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
|
|
5
|
+
from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker, CircuitBreaker
|
|
6
|
+
from httpware.middleware.resilience.retry import AsyncRetry, Retry
|
|
7
|
+
from httpware.middleware.resilience.timeout import AsyncTimeout
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AsyncBulkhead",
|
|
12
|
+
"AsyncCircuitBreaker",
|
|
13
|
+
"AsyncRetry",
|
|
14
|
+
"AsyncTimeout",
|
|
15
|
+
"Bulkhead",
|
|
16
|
+
"CircuitBreaker",
|
|
17
|
+
"Retry",
|
|
18
|
+
"RetryBudget",
|
|
19
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""CircuitBreaker + AsyncCircuitBreaker — classic consecutive-failure circuit breaker.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-13-circuit-breaker-and-timeout-design.md for the contract.
|
|
4
|
+
|
|
5
|
+
A counted failure is a NetworkError, an httpware TimeoutError, or a StatusError whose
|
|
6
|
+
status_code is in the effective failure set (default: all 5xx). 4xx — including 429 —
|
|
7
|
+
count as successes: 429 means healthy-but-throttling, and tripping on it amplifies
|
|
8
|
+
incidents. Any other exception propagates without affecting circuit state.
|
|
9
|
+
|
|
10
|
+
State machine (classic / consecutive-failure):
|
|
11
|
+
CLOSED — forward; count consecutive counted-failures; open at failure_threshold.
|
|
12
|
+
OPEN — fast-fail with CircuitOpenError; after reset_timeout the next request
|
|
13
|
+
becomes the half-open probe.
|
|
14
|
+
HALF_OPEN — admit exactly one probe at a time; success_threshold consecutive probe
|
|
15
|
+
successes close the circuit; one probe failure re-opens it.
|
|
16
|
+
|
|
17
|
+
The lock-free _CircuitBreakerState holds the transition logic, shared by both wrappers.
|
|
18
|
+
AsyncCircuitBreaker relies on asyncio atomicity (no await inside a transition) plus a
|
|
19
|
+
single-event-loop guard; CircuitBreaker (sync) serializes transitions with a
|
|
20
|
+
threading.Lock. Both are sharable across clients (one shared circuit); a sync instance
|
|
21
|
+
cannot be shared with an async one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import enum
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import typing
|
|
30
|
+
from collections.abc import Callable, Collection
|
|
31
|
+
|
|
32
|
+
import httpx2
|
|
33
|
+
|
|
34
|
+
from httpware._internal.observability import _emit_event
|
|
35
|
+
from httpware.errors import CircuitOpenError, NetworkError, StatusError, TimeoutError # noqa: A004
|
|
36
|
+
from httpware.middleware import AsyncNext, Next
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_FAILURE_THRESHOLD_INVALID = "failure_threshold must be >= 1"
|
|
40
|
+
_RESET_TIMEOUT_INVALID = "reset_timeout must be >= 0"
|
|
41
|
+
_SUCCESS_THRESHOLD_INVALID = "success_threshold must be >= 1"
|
|
42
|
+
_CROSS_LOOP_MSG = (
|
|
43
|
+
"AsyncCircuitBreaker is bound to a single event loop. First seen on {first!r}; "
|
|
44
|
+
"current request is on {current!r}. Use one AsyncCircuitBreaker per loop; "
|
|
45
|
+
"cross-thread sharing requires the sync CircuitBreaker primitive."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_DEFAULT_FAILURE_STATUS_CODES = frozenset(range(500, 600))
|
|
49
|
+
|
|
50
|
+
_ROLE_CLOSED = "closed"
|
|
51
|
+
_ROLE_PROBE = "probe"
|
|
52
|
+
|
|
53
|
+
_LOGGER = logging.getLogger("httpware.circuit_breaker")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _CircuitState(enum.Enum):
|
|
57
|
+
CLOSED = "closed"
|
|
58
|
+
OPEN = "open"
|
|
59
|
+
HALF_OPEN = "half_open"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _CircuitBreakerState:
|
|
63
|
+
"""Lock-free circuit-breaker state machine shared by the sync + async wrappers.
|
|
64
|
+
|
|
65
|
+
Every method is synchronous and performs no I/O beyond logging. The async wrapper
|
|
66
|
+
calls these directly (atomic under a single event loop because no await occurs
|
|
67
|
+
inside a transition); the sync wrapper wraps each call in a threading.Lock.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
failure_threshold: int,
|
|
74
|
+
reset_timeout: float,
|
|
75
|
+
success_threshold: int,
|
|
76
|
+
failure_status_codes: Collection[int] | None,
|
|
77
|
+
now: Callable[[], float],
|
|
78
|
+
) -> None:
|
|
79
|
+
if failure_threshold < 1:
|
|
80
|
+
raise ValueError(_FAILURE_THRESHOLD_INVALID)
|
|
81
|
+
if reset_timeout < 0:
|
|
82
|
+
raise ValueError(_RESET_TIMEOUT_INVALID)
|
|
83
|
+
if success_threshold < 1:
|
|
84
|
+
raise ValueError(_SUCCESS_THRESHOLD_INVALID)
|
|
85
|
+
self._failure_threshold = failure_threshold
|
|
86
|
+
self._reset_timeout = reset_timeout
|
|
87
|
+
self._success_threshold = success_threshold
|
|
88
|
+
# Accept any Collection (set, frozenset, list, ...) and freeze it so callers
|
|
89
|
+
# aren't forced to construct a frozenset just to satisfy the type checker.
|
|
90
|
+
self._failure_status_codes = (
|
|
91
|
+
frozenset(failure_status_codes) if failure_status_codes is not None else _DEFAULT_FAILURE_STATUS_CODES
|
|
92
|
+
)
|
|
93
|
+
self._now = now
|
|
94
|
+
self._state = _CircuitState.CLOSED
|
|
95
|
+
self._consecutive_failures = 0
|
|
96
|
+
self._consecutive_successes = 0
|
|
97
|
+
self._opened_at = 0.0
|
|
98
|
+
self._probe_in_flight = False
|
|
99
|
+
|
|
100
|
+
def is_failure_status(self, status_code: int) -> bool:
|
|
101
|
+
return status_code in self._failure_status_codes
|
|
102
|
+
|
|
103
|
+
def admit(self, request: httpx2.Request) -> str:
|
|
104
|
+
"""Decide the request's role, or raise CircuitOpenError. No await inside."""
|
|
105
|
+
if self._state is _CircuitState.CLOSED:
|
|
106
|
+
return _ROLE_CLOSED
|
|
107
|
+
if self._state is _CircuitState.OPEN:
|
|
108
|
+
elapsed = self._now() - self._opened_at
|
|
109
|
+
if elapsed >= self._reset_timeout:
|
|
110
|
+
self._state = _CircuitState.HALF_OPEN
|
|
111
|
+
self._probe_in_flight = True
|
|
112
|
+
self._emit(request, "circuit.half_open", logging.INFO, "circuit half-open — admitting probe", {})
|
|
113
|
+
return _ROLE_PROBE
|
|
114
|
+
retry_after = max(0.0, self._reset_timeout - elapsed)
|
|
115
|
+
self._emit(
|
|
116
|
+
request,
|
|
117
|
+
"circuit.rejected",
|
|
118
|
+
logging.WARNING,
|
|
119
|
+
"circuit open — rejecting request",
|
|
120
|
+
{"retry_after": retry_after},
|
|
121
|
+
)
|
|
122
|
+
raise CircuitOpenError(retry_after=retry_after)
|
|
123
|
+
# HALF_OPEN
|
|
124
|
+
if self._probe_in_flight:
|
|
125
|
+
self._emit(
|
|
126
|
+
request,
|
|
127
|
+
"circuit.rejected",
|
|
128
|
+
logging.WARNING,
|
|
129
|
+
"circuit half-open — rejecting request (probe in flight)",
|
|
130
|
+
{"retry_after": None},
|
|
131
|
+
)
|
|
132
|
+
raise CircuitOpenError(retry_after=None)
|
|
133
|
+
self._probe_in_flight = True
|
|
134
|
+
return _ROLE_PROBE
|
|
135
|
+
|
|
136
|
+
def on_success(self, role: str, request: httpx2.Request) -> None:
|
|
137
|
+
if role == _ROLE_PROBE:
|
|
138
|
+
self._probe_in_flight = False
|
|
139
|
+
if self._state is _CircuitState.CLOSED:
|
|
140
|
+
self._consecutive_failures = 0
|
|
141
|
+
elif self._state is _CircuitState.HALF_OPEN:
|
|
142
|
+
self._consecutive_successes += 1
|
|
143
|
+
if self._consecutive_successes >= self._success_threshold:
|
|
144
|
+
self._state = _CircuitState.CLOSED
|
|
145
|
+
self._consecutive_failures = 0
|
|
146
|
+
self._consecutive_successes = 0
|
|
147
|
+
self._emit(request, "circuit.closed", logging.INFO, "circuit closed — service recovered", {})
|
|
148
|
+
|
|
149
|
+
def on_failure(self, role: str, request: httpx2.Request) -> None:
|
|
150
|
+
if role == _ROLE_PROBE:
|
|
151
|
+
self._probe_in_flight = False
|
|
152
|
+
if self._state is _CircuitState.CLOSED:
|
|
153
|
+
self._consecutive_failures += 1
|
|
154
|
+
if self._consecutive_failures >= self._failure_threshold:
|
|
155
|
+
self._open(request, failures=self._consecutive_failures)
|
|
156
|
+
elif self._state is _CircuitState.HALF_OPEN:
|
|
157
|
+
self._open(request, failures=1) # 1 = the single probe failure that re-opened the circuit
|
|
158
|
+
|
|
159
|
+
def release_probe(self, role: str) -> None:
|
|
160
|
+
"""Release the probe slot without recording success or failure (non-counted exc)."""
|
|
161
|
+
if role == _ROLE_PROBE:
|
|
162
|
+
self._probe_in_flight = False
|
|
163
|
+
|
|
164
|
+
def _open(self, request: httpx2.Request, *, failures: int) -> None:
|
|
165
|
+
self._state = _CircuitState.OPEN
|
|
166
|
+
self._opened_at = self._now()
|
|
167
|
+
self._consecutive_failures = 0
|
|
168
|
+
self._consecutive_successes = 0
|
|
169
|
+
self._emit(
|
|
170
|
+
request,
|
|
171
|
+
"circuit.opened",
|
|
172
|
+
logging.WARNING,
|
|
173
|
+
"circuit opened — failure threshold reached",
|
|
174
|
+
{"failure_threshold": self._failure_threshold, "failures": failures},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _emit(
|
|
178
|
+
self,
|
|
179
|
+
request: httpx2.Request,
|
|
180
|
+
event_name: str,
|
|
181
|
+
level: int,
|
|
182
|
+
message: str,
|
|
183
|
+
attributes: dict[str, typing.Any],
|
|
184
|
+
) -> None:
|
|
185
|
+
_emit_event(
|
|
186
|
+
_LOGGER,
|
|
187
|
+
event_name,
|
|
188
|
+
level=level,
|
|
189
|
+
message=message,
|
|
190
|
+
attributes={**attributes, "method": request.method, "url": str(request.url)},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AsyncCircuitBreaker:
|
|
195
|
+
"""Async classic circuit breaker middleware. See the module docstring for the contract."""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
failure_threshold: int = 5,
|
|
201
|
+
reset_timeout: float = 30.0,
|
|
202
|
+
success_threshold: int = 1,
|
|
203
|
+
failure_status_codes: Collection[int] | None = None,
|
|
204
|
+
_now: Callable[[], float] = time.monotonic,
|
|
205
|
+
) -> None:
|
|
206
|
+
self._state = _CircuitBreakerState(
|
|
207
|
+
failure_threshold=failure_threshold,
|
|
208
|
+
reset_timeout=reset_timeout,
|
|
209
|
+
success_threshold=success_threshold,
|
|
210
|
+
failure_status_codes=failure_status_codes,
|
|
211
|
+
now=_now,
|
|
212
|
+
)
|
|
213
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
214
|
+
self._loop_lock = threading.Lock()
|
|
215
|
+
|
|
216
|
+
def _check_loop(self) -> None:
|
|
217
|
+
current = asyncio.get_running_loop()
|
|
218
|
+
cached = self._loop
|
|
219
|
+
if cached is current:
|
|
220
|
+
return
|
|
221
|
+
if cached is not None:
|
|
222
|
+
raise RuntimeError(_CROSS_LOOP_MSG.format(first=cached, current=current))
|
|
223
|
+
with self._loop_lock:
|
|
224
|
+
if self._loop is None:
|
|
225
|
+
self._loop = current
|
|
226
|
+
# pragma below: inner double-check-with-lock race arm; only reachable when
|
|
227
|
+
# two threads simultaneously pass the outer check, which single-threaded
|
|
228
|
+
# tests can't trigger.
|
|
229
|
+
elif self._loop is not current: # pragma: no cover
|
|
230
|
+
raise RuntimeError(_CROSS_LOOP_MSG.format(first=self._loop, current=current))
|
|
231
|
+
|
|
232
|
+
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
233
|
+
"""Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
|
|
234
|
+
self._check_loop()
|
|
235
|
+
role = self._state.admit(request)
|
|
236
|
+
try:
|
|
237
|
+
response = await next(request)
|
|
238
|
+
except StatusError as exc:
|
|
239
|
+
if self._state.is_failure_status(exc.response.status_code):
|
|
240
|
+
self._state.on_failure(role, request)
|
|
241
|
+
else:
|
|
242
|
+
self._state.on_success(role, request)
|
|
243
|
+
raise
|
|
244
|
+
except (NetworkError, TimeoutError):
|
|
245
|
+
self._state.on_failure(role, request)
|
|
246
|
+
raise
|
|
247
|
+
except BaseException:
|
|
248
|
+
self._state.release_probe(role)
|
|
249
|
+
raise
|
|
250
|
+
self._state.on_success(role, request)
|
|
251
|
+
return response
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class CircuitBreaker:
|
|
255
|
+
"""Sync classic circuit breaker middleware. Mirror of AsyncCircuitBreaker.
|
|
256
|
+
|
|
257
|
+
Serializes every state transition with a threading.Lock. Sharable across Clients
|
|
258
|
+
(one shared circuit); a sync instance cannot be shared with an AsyncClient.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
failure_threshold: int = 5,
|
|
265
|
+
reset_timeout: float = 30.0,
|
|
266
|
+
success_threshold: int = 1,
|
|
267
|
+
failure_status_codes: Collection[int] | None = None,
|
|
268
|
+
_now: Callable[[], float] = time.monotonic,
|
|
269
|
+
) -> None:
|
|
270
|
+
self._state = _CircuitBreakerState(
|
|
271
|
+
failure_threshold=failure_threshold,
|
|
272
|
+
reset_timeout=reset_timeout,
|
|
273
|
+
success_threshold=success_threshold,
|
|
274
|
+
failure_status_codes=failure_status_codes,
|
|
275
|
+
now=_now,
|
|
276
|
+
)
|
|
277
|
+
self._lock = threading.Lock()
|
|
278
|
+
|
|
279
|
+
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
|
|
280
|
+
"""Admit, forward, then record the outcome. Fast-fail when the circuit is not closed."""
|
|
281
|
+
with self._lock:
|
|
282
|
+
role = self._state.admit(request)
|
|
283
|
+
try:
|
|
284
|
+
response = next(request)
|
|
285
|
+
except StatusError as exc:
|
|
286
|
+
with self._lock:
|
|
287
|
+
if self._state.is_failure_status(exc.response.status_code):
|
|
288
|
+
self._state.on_failure(role, request)
|
|
289
|
+
else:
|
|
290
|
+
self._state.on_success(role, request)
|
|
291
|
+
raise
|
|
292
|
+
except (NetworkError, TimeoutError):
|
|
293
|
+
with self._lock:
|
|
294
|
+
self._state.on_failure(role, request)
|
|
295
|
+
raise
|
|
296
|
+
except BaseException:
|
|
297
|
+
with self._lock:
|
|
298
|
+
self._state.release_probe(role)
|
|
299
|
+
raise
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._state.on_success(role, request)
|
|
302
|
+
return response
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""AsyncTimeout middleware — overall wall-clock deadline across the inner pipeline.
|
|
2
|
+
|
|
3
|
+
This is NOT a per-call timeout — httpx2's connect/read/write/pool timeouts are the
|
|
4
|
+
right tool for bounding a single outbound call, and AsyncTimeout does not duplicate
|
|
5
|
+
them. What httpx2 cannot bound is the total wall-clock across the whole middleware
|
|
6
|
+
pipeline (most importantly across an AsyncRetry loop, whose attempts and backoff
|
|
7
|
+
sleeps it knows nothing about). Place AsyncTimeout outermost to enforce
|
|
8
|
+
"this whole operation must finish within `timeout` seconds, even across retries."
|
|
9
|
+
|
|
10
|
+
Async-only by design: a sync total-deadline cannot interrupt a blocking httpx2 call
|
|
11
|
+
mid-flight (sync Python has no cancellation), and httpx2 already covers sync per-call
|
|
12
|
+
timeouts. Sync callers configure httpx2's timeouts directly; there is no sync Timeout.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
import httpx2
|
|
19
|
+
|
|
20
|
+
from httpware._internal.observability import _emit_event
|
|
21
|
+
from httpware.errors import TimeoutError as HttpwareTimeoutError
|
|
22
|
+
from httpware.middleware import AsyncNext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_TIMEOUT_INVALID = "timeout must be > 0"
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger("httpware.timeout")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncTimeout:
|
|
31
|
+
"""Bounds total wall-clock time spent in the inner pipeline.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
timeout
|
|
36
|
+
Required. Overall deadline in seconds for ``next(request)`` to complete,
|
|
37
|
+
including everything it wraps (retries, backoff sleeps, the call itself).
|
|
38
|
+
Must be ``> 0``. On expiry the middleware raises ``httpware.TimeoutError``.
|
|
39
|
+
|
|
40
|
+
Place outermost in the chain for an overall-operation deadline. For bounding a
|
|
41
|
+
single outbound call (connect/read/write/pool), configure ``httpx2`` instead.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, timeout: float) -> None:
|
|
46
|
+
if timeout <= 0:
|
|
47
|
+
raise ValueError(_TIMEOUT_INVALID)
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
|
|
50
|
+
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
51
|
+
"""Invoke next under an asyncio.timeout; raise httpware.TimeoutError on expiry.
|
|
52
|
+
|
|
53
|
+
Only a deadline THIS middleware imposed is re-wrapped: ``cm.expired()``
|
|
54
|
+
distinguishes our own expiry from an inner ``TimeoutError`` (e.g. an httpx2
|
|
55
|
+
per-call timeout surfacing through a retry), which propagates unchanged.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
async with asyncio.timeout(self._timeout) as cm:
|
|
59
|
+
return await next(request)
|
|
60
|
+
except TimeoutError as exc:
|
|
61
|
+
if not cm.expired():
|
|
62
|
+
raise # inner TimeoutError, not our deadline — leave it untouched
|
|
63
|
+
_emit_event(
|
|
64
|
+
_LOGGER,
|
|
65
|
+
"timeout.exceeded",
|
|
66
|
+
level=logging.WARNING,
|
|
67
|
+
message="overall timeout exceeded",
|
|
68
|
+
attributes={
|
|
69
|
+
"timeout": self._timeout,
|
|
70
|
+
"method": request.method,
|
|
71
|
+
"url": str(request.url),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
msg = f"overall timeout of {self._timeout}s exceeded"
|
|
75
|
+
raise HttpwareTimeoutError(msg) from exc
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
"""Resilience primitives: Bulkhead/AsyncBulkhead, Retry/AsyncRetry, RetryBudget."""
|
|
2
|
-
|
|
3
|
-
from httpware.middleware.resilience.budget import RetryBudget
|
|
4
|
-
from httpware.middleware.resilience.bulkhead import AsyncBulkhead, Bulkhead
|
|
5
|
-
from httpware.middleware.resilience.retry import AsyncRetry, Retry
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
__all__ = ["AsyncBulkhead", "AsyncRetry", "Bulkhead", "Retry", "RetryBudget"]
|
|
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
|