httpware 0.9.0__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.0 → httpware-0.10.0}/PKG-INFO +8 -6
- {httpware-0.9.0 → httpware-0.10.0}/README.md +7 -5
- {httpware-0.9.0 → httpware-0.10.0}/pyproject.toml +1 -1
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/__init__.py +15 -1
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/decoders/__init__.py +7 -0
- httpware-0.10.0/src/httpware/decoders/msgspec.py +116 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/decoders/pydantic.py +19 -0
- {httpware-0.9.0 → 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.0/src/httpware/decoders/msgspec.py +0 -66
- httpware-0.9.0/src/httpware/middleware/resilience/__init__.py +0 -8
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/client.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.9.0 → httpware-0.10.0}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.9.0 → 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
|
|
|
@@ -80,7 +80,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
80
80
|
print(response.json())
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Typed decoding via `response_model=` works in both worlds —
|
|
83
|
+
Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
84
84
|
|
|
85
85
|
```python
|
|
86
86
|
from httpware import AsyncClient
|
|
@@ -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
|
|
|
@@ -50,7 +50,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
50
50
|
print(response.json())
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Typed decoding via `response_model=` works in both worlds —
|
|
53
|
+
Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
54
54
|
|
|
55
55
|
```python
|
|
56
56
|
from httpware import AsyncClient
|
|
@@ -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",
|
|
@@ -19,6 +19,13 @@ class ResponseDecoder(Protocol):
|
|
|
19
19
|
list ordering encodes the caller's preference for shared shapes.
|
|
20
20
|
Native types of another library (e.g. `PydanticDecoder` vs
|
|
21
21
|
`msgspec.Struct`) MUST be rejected.
|
|
22
|
+
|
|
23
|
+
`can_decode` MUST NOT raise. It runs at dispatch time — before the HTTP
|
|
24
|
+
call and outside the `DecodeError` wrap that protects `decode` — so an
|
|
25
|
+
exception here escapes the `ClientError` contract rather than being
|
|
26
|
+
translated. A decoder that cannot determine support for `model` must
|
|
27
|
+
return False (decline), not raise; the built-in decoders treat any
|
|
28
|
+
probe failure as False.
|
|
22
29
|
"""
|
|
23
30
|
...
|
|
24
31
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from httpware._internal import import_checker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if import_checker.is_msgspec_installed:
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
|
|
19
|
+
"""Return True if `info` is a CustomType or nests one in its parameters.
|
|
20
|
+
|
|
21
|
+
Walks generic-container parameterization (list/dict/set/tuple/union element
|
|
22
|
+
types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
|
|
23
|
+
tuple of them. It deliberately does NOT descend into `StructType`/dataclass
|
|
24
|
+
fields: those expose `fields` as `Field` objects (not `Type`), so the walk
|
|
25
|
+
stops at the boundary of a type msgspec natively owns. That boundary is what
|
|
26
|
+
makes the walk both correct (a Struct is a valid target) and safe against
|
|
27
|
+
infinite recursion on self-referential struct definitions.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(info, msgspec.inspect.CustomType):
|
|
30
|
+
return True
|
|
31
|
+
for name in dir(info):
|
|
32
|
+
if name.startswith("_"):
|
|
33
|
+
continue
|
|
34
|
+
value = getattr(info, name, None)
|
|
35
|
+
if isinstance(value, msgspec.inspect.Type):
|
|
36
|
+
if _contains_custom_type(value):
|
|
37
|
+
return True
|
|
38
|
+
elif (
|
|
39
|
+
isinstance(value, tuple)
|
|
40
|
+
and value
|
|
41
|
+
and all(isinstance(item, msgspec.inspect.Type) for item in value)
|
|
42
|
+
and any(_contains_custom_type(item) for item in value)
|
|
43
|
+
):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MsgspecDecoder:
|
|
49
|
+
"""Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
|
|
50
|
+
|
|
51
|
+
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
|
|
52
|
+
this module without the extra works (the `msgspec` import is guarded by a
|
|
53
|
+
`find_spec` check), but instantiating the decoder raises `ImportError`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
|
|
57
|
+
_can_decode_results: dict[type, bool]
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
if not import_checker.is_msgspec_installed:
|
|
61
|
+
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
62
|
+
self._msgspec_decoders = {}
|
|
63
|
+
self._can_decode_results = {}
|
|
64
|
+
|
|
65
|
+
def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
|
|
66
|
+
decoder = self._msgspec_decoders.get(model)
|
|
67
|
+
if decoder is None:
|
|
68
|
+
decoder = msgspec.json.Decoder(model)
|
|
69
|
+
self._msgspec_decoders[model] = decoder
|
|
70
|
+
return decoder
|
|
71
|
+
|
|
72
|
+
def can_decode(self, model: type) -> bool:
|
|
73
|
+
"""Return True iff msgspec natively understands `model` end-to-end.
|
|
74
|
+
|
|
75
|
+
The verdict is memoized per `model`: the probe below (an uncached
|
|
76
|
+
`type_info` call plus a recursive tree walk) runs once per type, not on
|
|
77
|
+
every dispatch. Unhashable models skip the cache and probe fresh.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
cached = self._can_decode_results.get(model)
|
|
81
|
+
except TypeError: # unhashable model — can't memoize, probe fresh
|
|
82
|
+
return self._probe_can_decode(model)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
result = self._probe_can_decode(model)
|
|
86
|
+
self._can_decode_results[model] = result
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
def _probe_can_decode(self, model: type) -> bool:
|
|
90
|
+
"""Decide whether msgspec natively decodes `model` (the uncached path).
|
|
91
|
+
|
|
92
|
+
msgspec builds a Decoder for almost any class via a generic CustomType
|
|
93
|
+
fallback; the Decoder constructor does NOT raise on unsupported types
|
|
94
|
+
(e.g. pydantic.BaseModel, or a container parameterized by one). We walk
|
|
95
|
+
msgspec.inspect.type_info and reject if a CustomType appears anywhere in
|
|
96
|
+
the type tree, so MissingDecoderError fires before a request is sent.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
info = msgspec.inspect.type_info(model)
|
|
100
|
+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
101
|
+
return False
|
|
102
|
+
if _contains_custom_type(info):
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
self._get_msgspec_decoder(model)
|
|
106
|
+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
111
|
+
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
112
|
+
try:
|
|
113
|
+
decoder = self._get_msgspec_decoder(model)
|
|
114
|
+
except TypeError:
|
|
115
|
+
decoder = msgspec.json.Decoder(model)
|
|
116
|
+
return decoder.decode(content)
|
|
@@ -26,11 +26,13 @@ class PydanticDecoder:
|
|
|
26
26
|
"""Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
|
|
27
27
|
|
|
28
28
|
_adapters: dict[type, TypeAdapter[typing.Any]]
|
|
29
|
+
_can_decode_results: dict[type, bool]
|
|
29
30
|
|
|
30
31
|
def __init__(self) -> None:
|
|
31
32
|
if not import_checker.is_pydantic_installed:
|
|
32
33
|
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
33
34
|
self._adapters = {}
|
|
35
|
+
self._can_decode_results = {}
|
|
34
36
|
|
|
35
37
|
def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
|
|
36
38
|
adapter = self._adapters.get(model)
|
|
@@ -42,6 +44,23 @@ class PydanticDecoder:
|
|
|
42
44
|
def can_decode(self, model: type) -> bool:
|
|
43
45
|
"""Return True iff pydantic can build a schema for `model`.
|
|
44
46
|
|
|
47
|
+
The verdict is memoized per `model` so a rejection (which costs a
|
|
48
|
+
`PydanticSchemaGenerationError` round-trip) is not re-probed on every
|
|
49
|
+
dispatch. Unhashable models skip the cache and probe fresh.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
cached = self._can_decode_results.get(model)
|
|
53
|
+
except TypeError: # unhashable model — can't memoize, probe fresh
|
|
54
|
+
return self._probe_can_decode(model)
|
|
55
|
+
if cached is not None:
|
|
56
|
+
return cached
|
|
57
|
+
result = self._probe_can_decode(model)
|
|
58
|
+
self._can_decode_results[model] = result
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def _probe_can_decode(self, model: type) -> bool:
|
|
62
|
+
"""Decide whether pydantic can build a schema for `model` (uncached).
|
|
63
|
+
|
|
45
64
|
Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
|
|
46
65
|
the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
|
|
47
66
|
pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
|
|
@@ -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,66 +0,0 @@
|
|
|
1
|
-
"""MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
|
|
2
|
-
|
|
3
|
-
import typing
|
|
4
|
-
from typing import TypeVar
|
|
5
|
-
|
|
6
|
-
from httpware._internal import import_checker
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if import_checker.is_msgspec_installed:
|
|
10
|
-
import msgspec
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
|
|
14
|
-
|
|
15
|
-
T = TypeVar("T")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class MsgspecDecoder:
|
|
19
|
-
"""Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
|
|
20
|
-
|
|
21
|
-
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
|
|
22
|
-
this module without the extra works (the `msgspec` import is guarded by a
|
|
23
|
-
`find_spec` check), but instantiating the decoder raises `ImportError`.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
_msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
|
|
27
|
-
|
|
28
|
-
def __init__(self) -> None:
|
|
29
|
-
if not import_checker.is_msgspec_installed:
|
|
30
|
-
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
31
|
-
self._msgspec_decoders = {}
|
|
32
|
-
|
|
33
|
-
def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
|
|
34
|
-
decoder = self._msgspec_decoders.get(model)
|
|
35
|
-
if decoder is None:
|
|
36
|
-
decoder = msgspec.json.Decoder(model)
|
|
37
|
-
self._msgspec_decoders[model] = decoder
|
|
38
|
-
return decoder
|
|
39
|
-
|
|
40
|
-
def can_decode(self, model: type) -> bool:
|
|
41
|
-
"""Return True iff msgspec natively understands `model`.
|
|
42
|
-
|
|
43
|
-
msgspec builds a Decoder for almost any class via a generic CustomType
|
|
44
|
-
fallback; the Decoder constructor itself does NOT raise on unsupported
|
|
45
|
-
types (e.g. pydantic.BaseModel). We use msgspec.inspect.type_info
|
|
46
|
-
to detect the fallback and reject CustomType results explicitly.
|
|
47
|
-
"""
|
|
48
|
-
try:
|
|
49
|
-
info = msgspec.inspect.type_info(model)
|
|
50
|
-
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
51
|
-
return False
|
|
52
|
-
if isinstance(info, msgspec.inspect.CustomType):
|
|
53
|
-
return False
|
|
54
|
-
try:
|
|
55
|
-
self._get_msgspec_decoder(model)
|
|
56
|
-
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
57
|
-
return False
|
|
58
|
-
return True
|
|
59
|
-
|
|
60
|
-
def decode(self, content: bytes, model: type[T]) -> T:
|
|
61
|
-
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
62
|
-
try:
|
|
63
|
-
decoder = self._get_msgspec_decoder(model)
|
|
64
|
-
except TypeError:
|
|
65
|
-
decoder = msgspec.json.Decoder(model)
|
|
66
|
-
return decoder.decode(content)
|
|
@@ -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
|