httpware 0.13.0__tar.gz → 0.15.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.15.0}/PKG-INFO +10 -5
- {httpware-0.13.0 → httpware-0.15.0}/pyproject.toml +15 -7
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/__init__.py +2 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/client.py +179 -90
- httpware-0.15.0/src/httpware/decoders/_resolver.py +64 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/errors.py +31 -13
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/__init__.py +2 -1
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/circuit_breaker.py +34 -11
- httpware-0.15.0/src/httpware/middleware/resilience/retry.py +297 -0
- httpware-0.13.0/src/httpware/middleware/resilience/retry.py +0 -349
- {httpware-0.13.0 → httpware-0.15.0}/README.md +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.13.0 → httpware-0.15.0}/src/httpware/py.typed +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
5
|
-
Keywords: http,async,
|
|
3
|
+
Version: 0.15.0
|
|
4
|
+
Summary: Python HTTP client framework with sync & async clients and built-in resilience
|
|
5
|
+
Keywords: http-client,httpx,async,asyncio,resilience,retry,circuit-breaker,bulkhead,middleware,pydantic,msgspec,python
|
|
6
6
|
Author: Artur Shiriev
|
|
7
7
|
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
8
|
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
9
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
10
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
11
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -20,8 +22,11 @@ Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
|
20
22
|
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
21
23
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
22
24
|
Requires-Python: >=3.11, <4
|
|
23
|
-
Project-URL:
|
|
24
|
-
Project-URL:
|
|
25
|
+
Project-URL: Homepage, https://httpware.modern-python.org
|
|
26
|
+
Project-URL: Documentation, https://httpware.modern-python.org
|
|
27
|
+
Project-URL: Repository, https://github.com/modern-python/httpware
|
|
28
|
+
Project-URL: Issues, https://github.com/modern-python/httpware/issues
|
|
29
|
+
Project-URL: Changelog, https://github.com/modern-python/httpware/releases
|
|
25
30
|
Provides-Extra: all
|
|
26
31
|
Provides-Extra: msgspec
|
|
27
32
|
Provides-Extra: otel
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "httpware"
|
|
3
|
-
description = "
|
|
3
|
+
description = "Python HTTP client framework with sync & async clients and built-in resilience"
|
|
4
4
|
authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
|
|
5
5
|
requires-python = ">=3.11,<4"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
keywords = [
|
|
9
|
-
"http",
|
|
9
|
+
"http-client",
|
|
10
|
+
"httpx",
|
|
10
11
|
"async",
|
|
11
|
-
"
|
|
12
|
+
"asyncio",
|
|
12
13
|
"resilience",
|
|
13
14
|
"retry",
|
|
14
15
|
"circuit-breaker",
|
|
16
|
+
"bulkhead",
|
|
15
17
|
"middleware",
|
|
16
|
-
"httpx",
|
|
17
18
|
"pydantic",
|
|
19
|
+
"msgspec",
|
|
20
|
+
"python",
|
|
18
21
|
]
|
|
19
22
|
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
20
25
|
"Programming Language :: Python :: 3.11",
|
|
21
26
|
"Programming Language :: Python :: 3.12",
|
|
22
27
|
"Programming Language :: Python :: 3.13",
|
|
@@ -26,7 +31,7 @@ classifiers = [
|
|
|
26
31
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
32
|
"Framework :: AsyncIO",
|
|
28
33
|
]
|
|
29
|
-
version = "0.
|
|
34
|
+
version = "0.15.0"
|
|
30
35
|
dependencies = [
|
|
31
36
|
"httpx2>=2.0.0,<3.0",
|
|
32
37
|
]
|
|
@@ -38,8 +43,11 @@ otel = ["opentelemetry-api>=1.20"]
|
|
|
38
43
|
all = ["httpware[pydantic,msgspec,otel]"]
|
|
39
44
|
|
|
40
45
|
[project.urls]
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
Homepage = "https://httpware.modern-python.org"
|
|
47
|
+
Documentation = "https://httpware.modern-python.org"
|
|
48
|
+
Repository = "https://github.com/modern-python/httpware"
|
|
49
|
+
Issues = "https://github.com/modern-python/httpware/issues"
|
|
50
|
+
Changelog = "https://github.com/modern-python/httpware/releases"
|
|
43
51
|
|
|
44
52
|
[build-system]
|
|
45
53
|
requires = ["uv_build>=0.11,<1.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
|
import contextlib
|
|
4
4
|
import typing
|
|
5
|
-
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
6
6
|
from http import HTTPStatus
|
|
7
7
|
|
|
8
8
|
import httpx2
|
|
@@ -16,7 +16,8 @@ from httpware._internal.status import (
|
|
|
16
16
|
_raise_on_status_error,
|
|
17
17
|
)
|
|
18
18
|
from httpware.decoders import ResponseDecoder
|
|
19
|
-
from httpware.
|
|
19
|
+
from httpware.decoders._resolver import _DecoderResolver
|
|
20
|
+
from httpware.errors import ResponseTooLargeError, TransportError
|
|
20
21
|
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
|
|
21
22
|
from httpware.middleware.chain import compose, compose_async
|
|
22
23
|
|
|
@@ -31,6 +32,15 @@ _HTTPX2_CLIENT_CONFLICT_MESSAGE = (
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
_MAX_RESPONSE_BODY_BYTES_INVALID = "max_response_body_bytes must be >= 1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_max_response_body_bytes(cap: int | None) -> None:
|
|
39
|
+
"""Reject a non-None cap below 1. None means unbounded (the default)."""
|
|
40
|
+
if cap is not None and cap < 1:
|
|
41
|
+
raise ValueError(_MAX_RESPONSE_BODY_BYTES_INVALID)
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
def _parse_content_length(raw: str | None) -> int | None:
|
|
35
45
|
"""Return a non-negative int Content-Length, or None for missing/garbage. Never raises."""
|
|
36
46
|
if raw is None:
|
|
@@ -42,6 +52,123 @@ def _parse_content_length(raw: str | None) -> int | None:
|
|
|
42
52
|
return value if value >= 0 else None
|
|
43
53
|
|
|
44
54
|
|
|
55
|
+
class _CapExceeded(Exception): # noqa: N818 — internal control-flow signal, not a user-facing error
|
|
56
|
+
"""Internal signal: decoded bytes crossed the cap mid-read. Carries bytes read so far."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, read: int) -> None:
|
|
59
|
+
self.read = read
|
|
60
|
+
super().__init__(f"decoded body exceeded cap after {read} bytes")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _accumulate_capped(chunks: typing.Iterable[bytes], cap: int) -> bytes:
|
|
64
|
+
"""Concatenate `chunks`, raising `_CapExceeded` the moment the running total exceeds `cap`.
|
|
65
|
+
|
|
66
|
+
Counts decoded bytes (the in-memory footprint). Grown in a single bytearray
|
|
67
|
+
so there is no transient list-plus-join double allocation.
|
|
68
|
+
"""
|
|
69
|
+
buf = bytearray()
|
|
70
|
+
for chunk in chunks:
|
|
71
|
+
buf += chunk
|
|
72
|
+
if len(buf) > cap:
|
|
73
|
+
raise _CapExceeded(read=len(buf))
|
|
74
|
+
return bytes(buf)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_extensions(extensions: Mapping[str, typing.Any]) -> dict[str, typing.Any]:
|
|
78
|
+
"""Copy response extensions, dropping the now-stale `network_stream`.
|
|
79
|
+
|
|
80
|
+
The rebuilt buffered Response never touches its network stream, so carrying a
|
|
81
|
+
consumed/closed one wholesale is sloppy. `http_version`/`reason_phrase` and
|
|
82
|
+
any other keys are preserved.
|
|
83
|
+
"""
|
|
84
|
+
return {key: value for key, value in extensions.items() if key != "network_stream"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Headers describing the wire encoding of the body. The accumulator yields the
|
|
88
|
+
# DECODED body, so these no longer apply; httpx2 recomputes content-length from
|
|
89
|
+
# the buffered content. Carrying content-encoding forward makes httpx2 try to
|
|
90
|
+
# re-decode already-decoded bytes and raise.
|
|
91
|
+
_WIRE_BODY_HEADERS = ("content-encoding", "content-length", "transfer-encoding")
|
|
92
|
+
_BODILESS_STATUS = frozenset({HTTPStatus.NO_CONTENT, HTTPStatus.NOT_MODIFIED}) # 204, 304
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _buffered_headers(headers: httpx2.Headers) -> httpx2.Headers:
|
|
96
|
+
"""Copy `headers`, stripping wire-encoding headers stale after decoding+buffering."""
|
|
97
|
+
out = httpx2.Headers(headers)
|
|
98
|
+
for name in _WIRE_BODY_HEADERS:
|
|
99
|
+
if name in out:
|
|
100
|
+
del out[name]
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _response_has_body(method: str, status_code: int) -> bool:
|
|
105
|
+
"""Whether a response carries a message body (RFC 9110 §6.4.1).
|
|
106
|
+
|
|
107
|
+
HEAD responses and 204/304 never have a body regardless of a declared
|
|
108
|
+
Content-Length, so they must never trip the cap.
|
|
109
|
+
"""
|
|
110
|
+
return method.upper() != "HEAD" and status_code not in _BODILESS_STATUS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
|
|
114
|
+
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
|
|
115
|
+
|
|
116
|
+
Raises `ResponseTooLargeError` (reason="declared") if the declared
|
|
117
|
+
Content-Length already exceeds `cap` — before any byte is read — and
|
|
118
|
+
(reason="streamed") if the decoded body crosses `cap` mid-read. Does not
|
|
119
|
+
close `response`; the caller owns the stream lifecycle.
|
|
120
|
+
"""
|
|
121
|
+
if not _response_has_body(request.method, response.status_code):
|
|
122
|
+
response.read() # empty body; preserve the original response (and its headers)
|
|
123
|
+
return response
|
|
124
|
+
content_length = _parse_content_length(response.headers.get("content-length"))
|
|
125
|
+
if content_length is not None and content_length > cap:
|
|
126
|
+
raise ResponseTooLargeError(
|
|
127
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
content = _accumulate_capped(response.iter_bytes(), cap)
|
|
131
|
+
except _CapExceeded:
|
|
132
|
+
raise ResponseTooLargeError(
|
|
133
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
|
|
134
|
+
) from None
|
|
135
|
+
return httpx2.Response(
|
|
136
|
+
status_code=response.status_code,
|
|
137
|
+
headers=_buffered_headers(response.headers),
|
|
138
|
+
content=content,
|
|
139
|
+
request=request,
|
|
140
|
+
extensions=_safe_extensions(response.extensions),
|
|
141
|
+
history=response.history,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
|
|
146
|
+
"""Async mirror of `_read_capped` (counts decoded bytes from `aiter_bytes`)."""
|
|
147
|
+
if not _response_has_body(request.method, response.status_code):
|
|
148
|
+
await response.aread() # empty body; preserve the original response (and its headers)
|
|
149
|
+
return response
|
|
150
|
+
content_length = _parse_content_length(response.headers.get("content-length"))
|
|
151
|
+
if content_length is not None and content_length > cap:
|
|
152
|
+
raise ResponseTooLargeError(
|
|
153
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
|
|
154
|
+
)
|
|
155
|
+
buf = bytearray()
|
|
156
|
+
async for chunk in response.aiter_bytes():
|
|
157
|
+
buf += chunk
|
|
158
|
+
if len(buf) > cap:
|
|
159
|
+
raise ResponseTooLargeError(
|
|
160
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
|
|
161
|
+
)
|
|
162
|
+
return httpx2.Response(
|
|
163
|
+
status_code=response.status_code,
|
|
164
|
+
headers=_buffered_headers(response.headers),
|
|
165
|
+
content=bytes(buf),
|
|
166
|
+
request=request,
|
|
167
|
+
extensions=_safe_extensions(response.extensions),
|
|
168
|
+
history=response.history,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
45
172
|
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
|
|
46
173
|
"""Construct the default decoder tuple based on installed extras.
|
|
47
174
|
|
|
@@ -93,7 +220,7 @@ class AsyncClient:
|
|
|
93
220
|
_decoders: tuple[ResponseDecoder, ...]
|
|
94
221
|
_user_middleware: tuple[AsyncMiddleware, ...]
|
|
95
222
|
_dispatch: AsyncNext
|
|
96
|
-
|
|
223
|
+
_max_response_body_bytes: int | None
|
|
97
224
|
|
|
98
225
|
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
|
|
99
226
|
self,
|
|
@@ -108,8 +235,9 @@ class AsyncClient:
|
|
|
108
235
|
httpx2_client: httpx2.AsyncClient | None = None,
|
|
109
236
|
decoders: Sequence[ResponseDecoder] | None = None,
|
|
110
237
|
middleware: Sequence[AsyncMiddleware] = (),
|
|
111
|
-
|
|
238
|
+
max_response_body_bytes: int | None = None,
|
|
112
239
|
) -> None:
|
|
240
|
+
_validate_max_response_body_bytes(max_response_body_bytes)
|
|
113
241
|
if httpx2_client is not None:
|
|
114
242
|
forwarded = {
|
|
115
243
|
"base_url": base_url,
|
|
@@ -144,21 +272,23 @@ class AsyncClient:
|
|
|
144
272
|
self._owns_client = True
|
|
145
273
|
|
|
146
274
|
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
275
|
+
self._decoder_resolver = _DecoderResolver(self._decoders)
|
|
147
276
|
self._user_middleware = tuple(middleware)
|
|
148
277
|
self._dispatch = compose_async(self._user_middleware, self._terminal)
|
|
149
|
-
self.
|
|
150
|
-
|
|
151
|
-
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
152
|
-
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
153
|
-
for decoder in self._decoders:
|
|
154
|
-
if decoder.can_decode(model):
|
|
155
|
-
return decoder
|
|
156
|
-
return None
|
|
278
|
+
self._max_response_body_bytes = max_response_body_bytes
|
|
157
279
|
|
|
158
280
|
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
281
|
+
cap = self._max_response_body_bytes
|
|
159
282
|
try:
|
|
160
283
|
async with _httpx2_exception_mapper():
|
|
161
|
-
|
|
284
|
+
if cap is None:
|
|
285
|
+
response = await self._httpx2_client.send(request)
|
|
286
|
+
else:
|
|
287
|
+
streaming = await self._httpx2_client.send(request, stream=True)
|
|
288
|
+
try:
|
|
289
|
+
response = await _read_capped_async(streaming, cap, request)
|
|
290
|
+
finally:
|
|
291
|
+
await streaming.aclose()
|
|
162
292
|
except RuntimeError as exc:
|
|
163
293
|
if self._httpx2_client.is_closed:
|
|
164
294
|
raise TransportError(str(exc)) from exc
|
|
@@ -182,18 +312,9 @@ class AsyncClient:
|
|
|
182
312
|
if response_model is None:
|
|
183
313
|
return await self._dispatch(request)
|
|
184
314
|
|
|
185
|
-
|
|
186
|
-
if decoder is None:
|
|
187
|
-
raise MissingDecoderError(
|
|
188
|
-
model=response_model,
|
|
189
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
190
|
-
)
|
|
191
|
-
|
|
315
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
192
316
|
response = await self._dispatch(request)
|
|
193
|
-
|
|
194
|
-
return decoder.decode(response.content, response_model)
|
|
195
|
-
except Exception as exc:
|
|
196
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
317
|
+
return bound.decode(response)
|
|
197
318
|
|
|
198
319
|
async def send_with_response(
|
|
199
320
|
self,
|
|
@@ -210,19 +331,9 @@ class AsyncClient:
|
|
|
210
331
|
Not for streaming responses — decodes ``response.content``, which
|
|
211
332
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
212
333
|
"""
|
|
213
|
-
|
|
214
|
-
if decoder is None:
|
|
215
|
-
raise MissingDecoderError(
|
|
216
|
-
model=response_model,
|
|
217
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
218
|
-
)
|
|
219
|
-
|
|
334
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
220
335
|
response = await self._dispatch(request)
|
|
221
|
-
|
|
222
|
-
decoded = decoder.decode(response.content, response_model)
|
|
223
|
-
except Exception as exc:
|
|
224
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
225
|
-
return response, decoded
|
|
336
|
+
return response, bound.decode(response)
|
|
226
337
|
|
|
227
338
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
228
339
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
@@ -1039,16 +1150,13 @@ class AsyncClient:
|
|
|
1039
1150
|
|
|
1040
1151
|
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
|
|
1041
1152
|
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
)
|
|
1050
|
-
await response.aread() # pre-read body so exc.response.content works
|
|
1051
|
-
_raise_on_status_error(response)
|
|
1153
|
+
cap = self._max_response_body_bytes
|
|
1154
|
+
if cap is None:
|
|
1155
|
+
await response.aread() # pre-read body so exc.response.content works
|
|
1156
|
+
_raise_on_status_error(response)
|
|
1157
|
+
else:
|
|
1158
|
+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
|
|
1159
|
+
_raise_on_status_error(await _read_capped_async(response, cap, response.request))
|
|
1052
1160
|
yield response
|
|
1053
1161
|
|
|
1054
1162
|
async def __aenter__(self) -> typing.Self:
|
|
@@ -1084,7 +1192,7 @@ class Client:
|
|
|
1084
1192
|
_decoders: tuple[ResponseDecoder, ...]
|
|
1085
1193
|
_user_middleware: tuple[Middleware, ...]
|
|
1086
1194
|
_dispatch: Next
|
|
1087
|
-
|
|
1195
|
+
_max_response_body_bytes: int | None
|
|
1088
1196
|
|
|
1089
1197
|
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
|
|
1090
1198
|
self,
|
|
@@ -1099,8 +1207,9 @@ class Client:
|
|
|
1099
1207
|
httpx2_client: httpx2.Client | None = None,
|
|
1100
1208
|
decoders: Sequence[ResponseDecoder] | None = None,
|
|
1101
1209
|
middleware: Sequence[Middleware] = (),
|
|
1102
|
-
|
|
1210
|
+
max_response_body_bytes: int | None = None,
|
|
1103
1211
|
) -> None:
|
|
1212
|
+
_validate_max_response_body_bytes(max_response_body_bytes)
|
|
1104
1213
|
if httpx2_client is not None:
|
|
1105
1214
|
forwarded = {
|
|
1106
1215
|
"base_url": base_url,
|
|
@@ -1135,21 +1244,23 @@ class Client:
|
|
|
1135
1244
|
self._owns_client = True
|
|
1136
1245
|
|
|
1137
1246
|
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
1247
|
+
self._decoder_resolver = _DecoderResolver(self._decoders)
|
|
1138
1248
|
self._user_middleware = tuple(middleware)
|
|
1139
1249
|
self._dispatch = compose(self._user_middleware, self._terminal)
|
|
1140
|
-
self.
|
|
1141
|
-
|
|
1142
|
-
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
1143
|
-
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
1144
|
-
for decoder in self._decoders:
|
|
1145
|
-
if decoder.can_decode(model):
|
|
1146
|
-
return decoder
|
|
1147
|
-
return None
|
|
1250
|
+
self._max_response_body_bytes = max_response_body_bytes
|
|
1148
1251
|
|
|
1149
1252
|
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
1253
|
+
cap = self._max_response_body_bytes
|
|
1150
1254
|
try:
|
|
1151
1255
|
with _httpx2_exception_mapper_sync():
|
|
1152
|
-
|
|
1256
|
+
if cap is None:
|
|
1257
|
+
response = self._httpx2_client.send(request)
|
|
1258
|
+
else:
|
|
1259
|
+
streaming = self._httpx2_client.send(request, stream=True)
|
|
1260
|
+
try:
|
|
1261
|
+
response = _read_capped(streaming, cap, request)
|
|
1262
|
+
finally:
|
|
1263
|
+
streaming.close()
|
|
1153
1264
|
except RuntimeError as exc:
|
|
1154
1265
|
if self._httpx2_client.is_closed:
|
|
1155
1266
|
raise TransportError(str(exc)) from exc
|
|
@@ -1197,18 +1308,9 @@ class Client:
|
|
|
1197
1308
|
if response_model is None:
|
|
1198
1309
|
return self._dispatch(request)
|
|
1199
1310
|
|
|
1200
|
-
|
|
1201
|
-
if decoder is None:
|
|
1202
|
-
raise MissingDecoderError(
|
|
1203
|
-
model=response_model,
|
|
1204
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
1205
|
-
)
|
|
1206
|
-
|
|
1311
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
1207
1312
|
response = self._dispatch(request)
|
|
1208
|
-
|
|
1209
|
-
return decoder.decode(response.content, response_model)
|
|
1210
|
-
except Exception as exc:
|
|
1211
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
1313
|
+
return bound.decode(response)
|
|
1212
1314
|
|
|
1213
1315
|
def send_with_response(
|
|
1214
1316
|
self,
|
|
@@ -1225,19 +1327,9 @@ class Client:
|
|
|
1225
1327
|
Not for streaming responses — decodes ``response.content``, which
|
|
1226
1328
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
1227
1329
|
"""
|
|
1228
|
-
|
|
1229
|
-
if decoder is None:
|
|
1230
|
-
raise MissingDecoderError(
|
|
1231
|
-
model=response_model,
|
|
1232
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
1233
|
-
)
|
|
1234
|
-
|
|
1330
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
1235
1331
|
response = self._dispatch(request)
|
|
1236
|
-
|
|
1237
|
-
decoded = decoder.decode(response.content, response_model)
|
|
1238
|
-
except Exception as exc:
|
|
1239
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
1240
|
-
return response, decoded
|
|
1332
|
+
return response, bound.decode(response)
|
|
1241
1333
|
|
|
1242
1334
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
1243
1335
|
"""Delegate request construction to the wrapped httpx2.Client."""
|
|
@@ -2052,14 +2144,11 @@ class Client:
|
|
|
2052
2144
|
|
|
2053
2145
|
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
|
|
2054
2146
|
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
)
|
|
2063
|
-
response.read() # pre-read body so exc.response.content works
|
|
2064
|
-
_raise_on_status_error(response)
|
|
2147
|
+
cap = self._max_response_body_bytes
|
|
2148
|
+
if cap is None:
|
|
2149
|
+
response.read() # pre-read body so exc.response.content works
|
|
2150
|
+
_raise_on_status_error(response)
|
|
2151
|
+
else:
|
|
2152
|
+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
|
|
2153
|
+
_raise_on_status_error(_read_capped(response, cap, response.request))
|
|
2065
2154
|
yield response
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The Seam B orchestrator: resolve a response_model to a claiming decoder, then decode.
|
|
2
|
+
|
|
3
|
+
`_DecoderResolver` walks the client's frozen `_decoders` tuple and returns the
|
|
4
|
+
first decoder whose `can_decode` claims the model, bound to that model as a
|
|
5
|
+
`_BoundDecoder`. Resolution (and the pre-flight `MissingDecoderError`) is a
|
|
6
|
+
separate step from decoding because the HTTP call happens between them: the
|
|
7
|
+
client calls `resolve` before `_dispatch` — so a missing decoder fails before
|
|
8
|
+
the request goes out — and `_BoundDecoder.decode` after the response arrives.
|
|
9
|
+
|
|
10
|
+
Both clients hold one `_DecoderResolver`; it is fully synchronous, so there is
|
|
11
|
+
no sync/async split. See architecture/decoders.md for the full Seam B contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Generic, TypeVar
|
|
15
|
+
|
|
16
|
+
import httpx2
|
|
17
|
+
|
|
18
|
+
from httpware.decoders import ResponseDecoder
|
|
19
|
+
from httpware.errors import DecodeError, MissingDecoderError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BoundDecoder(Generic[T]):
|
|
26
|
+
"""A `ResponseDecoder` sealed to the `model` it will decode into.
|
|
27
|
+
|
|
28
|
+
Binding the decoder and model together at resolve time makes a
|
|
29
|
+
decoder/model mismatch unrepresentable: the caller supplies only the
|
|
30
|
+
response. Decode failures are wrapped as `DecodeError` (the Seam B
|
|
31
|
+
contract — implementers never raise it directly).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None:
|
|
35
|
+
self._decoder = decoder
|
|
36
|
+
self._model = model
|
|
37
|
+
|
|
38
|
+
def decode(self, response: httpx2.Response) -> T:
|
|
39
|
+
"""Decode `response.content` into `model`, wrapping any failure as `DecodeError`."""
|
|
40
|
+
try:
|
|
41
|
+
return self._decoder.decode(response.content, self._model)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise DecodeError(response=response, model=self._model, original=exc) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DecoderResolver:
|
|
47
|
+
"""Resolves a `response_model` to the first claiming decoder in a frozen list."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None:
|
|
50
|
+
self._decoders = decoders
|
|
51
|
+
|
|
52
|
+
def resolve(self, model: type[T]) -> _BoundDecoder[T]:
|
|
53
|
+
"""Return the first decoder claiming `model`, bound to it.
|
|
54
|
+
|
|
55
|
+
Raises `MissingDecoderError` when no registered decoder claims `model`.
|
|
56
|
+
Called before the HTTP call, so the failure is pre-flight.
|
|
57
|
+
"""
|
|
58
|
+
for decoder in self._decoders:
|
|
59
|
+
if decoder.can_decode(model):
|
|
60
|
+
return _BoundDecoder(decoder, model)
|
|
61
|
+
raise MissingDecoderError(
|
|
62
|
+
model=model,
|
|
63
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
64
|
+
)
|
|
@@ -15,7 +15,7 @@ authors must redact those before logging.
|
|
|
15
15
|
|
|
16
16
|
import builtins
|
|
17
17
|
from collections.abc import Mapping
|
|
18
|
-
from typing import Any
|
|
18
|
+
from typing import Any, Literal
|
|
19
19
|
|
|
20
20
|
import httpx2
|
|
21
21
|
|
|
@@ -320,33 +320,51 @@ def _reconstruct_response_too_large(
|
|
|
320
320
|
status_code: int,
|
|
321
321
|
limit: int,
|
|
322
322
|
content_length: int | None,
|
|
323
|
+
reason: 'Literal["declared", "streamed"]',
|
|
323
324
|
) -> "ResponseTooLargeError":
|
|
324
|
-
return cls(status_code=status_code, limit=limit, content_length=content_length)
|
|
325
|
+
return cls(status_code=status_code, limit=limit, content_length=content_length, reason=reason)
|
|
325
326
|
|
|
326
327
|
|
|
327
328
|
class ResponseTooLargeError(ClientError):
|
|
328
|
-
"""Raised when
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
"""Raised when a response body exceeds the client's max_response_body_bytes cap.
|
|
330
|
+
|
|
331
|
+
Status-agnostic: fires on any non-streaming send() and on stream()'s internal
|
|
332
|
+
error pre-read, counting DECODED bytes. Only raised when
|
|
333
|
+
max_response_body_bytes is set (opt-in). `reason` discriminates the two trip
|
|
334
|
+
modes:
|
|
335
|
+
|
|
336
|
+
- "declared": the response's declared Content-Length already exceeds the cap,
|
|
337
|
+
so the body is rejected BEFORE a byte is read (`content_length` holds it).
|
|
338
|
+
- "streamed": the decoded body crossed the cap mid-read (the chunked or
|
|
339
|
+
compression-bomb case); `content_length` is whatever the server declared
|
|
340
|
+
and is unrelated to the cap. The true oversized size is unknown by design.
|
|
333
341
|
"""
|
|
334
342
|
|
|
335
343
|
status_code: int
|
|
336
344
|
limit: int
|
|
337
345
|
content_length: int | None
|
|
346
|
+
reason: Literal["declared", "streamed"]
|
|
338
347
|
|
|
339
|
-
def __init__(
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
status_code: int,
|
|
352
|
+
limit: int,
|
|
353
|
+
content_length: int | None,
|
|
354
|
+
reason: Literal["declared", "streamed"],
|
|
355
|
+
) -> None:
|
|
340
356
|
self.status_code = status_code
|
|
341
357
|
self.limit = limit
|
|
342
358
|
self.content_length = content_length
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
f"content_length={content_length} exceeds
|
|
346
|
-
|
|
359
|
+
self.reason = reason
|
|
360
|
+
if reason == "declared":
|
|
361
|
+
detail = f"declared content_length={content_length} exceeds max_response_body_bytes={limit}"
|
|
362
|
+
else:
|
|
363
|
+
detail = f"decoded body exceeded max_response_body_bytes={limit}"
|
|
364
|
+
super().__init__(f"response body too large: status={status_code} {detail}")
|
|
347
365
|
|
|
348
366
|
def __reduce__(self) -> tuple[Any, ...]:
|
|
349
367
|
return (
|
|
350
368
|
_reconstruct_response_too_large,
|
|
351
|
-
(type(self), self.status_code, self.limit, self.content_length),
|
|
369
|
+
(type(self), self.status_code, self.limit, self.content_length, self.reason),
|
|
352
370
|
)
|
|
@@ -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
|
]
|