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.
Files changed (27) hide show
  1. {httpware-0.13.0 → httpware-0.15.0}/PKG-INFO +10 -5
  2. {httpware-0.13.0 → httpware-0.15.0}/pyproject.toml +15 -7
  3. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/__init__.py +2 -0
  4. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/client.py +179 -90
  5. httpware-0.15.0/src/httpware/decoders/_resolver.py +64 -0
  6. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/errors.py +31 -13
  7. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/__init__.py +2 -1
  8. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/circuit_breaker.py +34 -11
  9. httpware-0.15.0/src/httpware/middleware/resilience/retry.py +297 -0
  10. httpware-0.13.0/src/httpware/middleware/resilience/retry.py +0 -349
  11. {httpware-0.13.0 → httpware-0.15.0}/README.md +0 -0
  12. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/__init__.py +0 -0
  13. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/exception_mapping.py +0 -0
  14. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/import_checker.py +0 -0
  15. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/observability.py +0 -0
  16. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/redaction.py +0 -0
  17. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/_internal/status.py +0 -0
  18. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/__init__.py +0 -0
  19. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/msgspec.py +0 -0
  20. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/decoders/pydantic.py +0 -0
  21. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/__init__.py +0 -0
  22. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/chain.py +0 -0
  23. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  24. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/budget.py +0 -0
  25. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  26. {httpware-0.13.0 → httpware-0.15.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  27. {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.13.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.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: 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
@@ -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.13.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
- 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"]
@@ -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.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
+ )
@@ -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 an error response body exceeds the client's max_error_body_bytes cap.
329
-
330
- Fires from stream() on a 4xx/5xx whose declared Content-Length exceeds the
331
- configured cap, BEFORE the body is read — so the oversized body is never
332
- buffered. Only raised when max_error_body_bytes is set (opt-in).
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__(self, *, status_code: int, limit: int, content_length: int | None) -> None:
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
- super().__init__(
344
- f"error response body too large: status={status_code} "
345
- f"content_length={content_length} exceeds max_error_body_bytes={limit}"
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
  ]