httpware 0.14.0__tar.gz → 0.15.1__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.
Files changed (27) hide show
  1. {httpware-0.14.0 → httpware-0.15.1}/PKG-INFO +19 -8
  2. {httpware-0.14.0 → httpware-0.15.1}/README.md +9 -3
  3. {httpware-0.14.0 → httpware-0.15.1}/pyproject.toml +15 -7
  4. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/client.py +179 -90
  5. httpware-0.15.1/src/httpware/decoders/_resolver.py +64 -0
  6. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/errors.py +31 -13
  7. httpware-0.15.1/src/httpware/middleware/resilience/retry.py +297 -0
  8. httpware-0.14.0/src/httpware/middleware/resilience/retry.py +0 -349
  9. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/__init__.py +0 -0
  10. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/__init__.py +0 -0
  11. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/exception_mapping.py +0 -0
  12. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/import_checker.py +0 -0
  13. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/observability.py +0 -0
  14. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/redaction.py +0 -0
  15. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/_internal/status.py +0 -0
  16. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/decoders/__init__.py +0 -0
  17. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/decoders/msgspec.py +0 -0
  18. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/decoders/pydantic.py +0 -0
  19. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/__init__.py +0 -0
  20. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/chain.py +0 -0
  21. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/__init__.py +0 -0
  22. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
  23. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/budget.py +0 -0
  24. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  25. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/circuit_breaker.py +0 -0
  26. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/middleware/resilience/timeout.py +0 -0
  27. {httpware-0.14.0 → httpware-0.15.1}/src/httpware/py.typed +0 -0
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.14.0
4
- Summary: Resilience-first async HTTP client framework for Python
5
- Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
3
+ Version: 0.15.1
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,19 +22,28 @@ 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: repository, https://github.com/modern-python/httpware
24
- Project-URL: docs, https://httpware.modern-python.org
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
28
33
  Provides-Extra: pydantic
29
34
  Description-Content-Type: text/markdown
30
35
 
31
- # httpware
36
+ <p align="center">
37
+ <picture>
38
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-dark.svg">
39
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-light.svg">
40
+ <img alt="httpware" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup.png" width="420">
41
+ </picture>
42
+ </p>
32
43
 
33
44
  [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
34
45
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
35
- [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
46
+ [![Downloads](https://static.pepy.tech/badge/httpware/month)](https://pepy.tech/projects/httpware)
36
47
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
37
48
  [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
38
49
  [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
@@ -42,7 +53,7 @@ Description-Content-Type: text/markdown
42
53
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
54
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
44
55
 
45
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
56
+ **Typed, resilient HTTP clients for Python — typed errors, typed response bodies, and composable resilience (retry, bulkhead, circuit breaker), sync or async.**
46
57
 
47
58
  ## Why httpware
48
59
 
@@ -1,8 +1,14 @@
1
- # httpware
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup-light.svg">
5
+ <img alt="httpware" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/httpware/lockup.png" width="420">
6
+ </picture>
7
+ </p>
2
8
 
3
9
  [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
4
10
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
5
- [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
11
+ [![Downloads](https://static.pepy.tech/badge/httpware/month)](https://pepy.tech/projects/httpware)
6
12
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
7
13
  [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
8
14
  [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
@@ -12,7 +18,7 @@
12
18
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
13
19
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
14
20
 
15
- **A Python HTTP client framework with sync and async clients for building resilient service clients.**
21
+ **Typed, resilient HTTP clients for Python — typed errors, typed response bodies, and composable resilience (retry, bulkhead, circuit breaker), sync or async.**
16
22
 
17
23
  ## Why httpware
18
24
 
@@ -1,22 +1,27 @@
1
1
  [project]
2
2
  name = "httpware"
3
- description = "Resilience-first async HTTP client framework for Python"
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
- "client",
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.14.0"
34
+ version = "0.15.1"
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
- repository = "https://github.com/modern-python/httpware"
42
- docs = "https://httpware.modern-python.org"
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"]
@@ -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.errors import DecodeError, MissingDecoderError, ResponseTooLargeError, TransportError
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
- _max_error_body_bytes: int | None
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
- max_error_body_bytes: int | None = None,
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._max_error_body_bytes = max_error_body_bytes
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
- response = await self._httpx2_client.send(request)
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
- decoder = self._dispatch_decoder(response_model)
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
- try:
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
- decoder = self._dispatch_decoder(response_model)
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
- try:
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
- if self._max_error_body_bytes is not None:
1043
- content_length = _parse_content_length(response.headers.get("content-length"))
1044
- if content_length is not None and content_length > self._max_error_body_bytes:
1045
- raise ResponseTooLargeError(
1046
- status_code=response.status_code,
1047
- limit=self._max_error_body_bytes,
1048
- content_length=content_length,
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
- _max_error_body_bytes: int | None
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
- max_error_body_bytes: int | None = None,
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._max_error_body_bytes = max_error_body_bytes
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
- response = self._httpx2_client.send(request)
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
- decoder = self._dispatch_decoder(response_model)
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
- try:
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
- decoder = self._dispatch_decoder(response_model)
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
- try:
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
- if self._max_error_body_bytes is not None:
2056
- content_length = _parse_content_length(response.headers.get("content-length"))
2057
- if content_length is not None and content_length > self._max_error_body_bytes:
2058
- raise ResponseTooLargeError(
2059
- status_code=response.status_code,
2060
- limit=self._max_error_body_bytes,
2061
- content_length=content_length,
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
+ )