Skip to content

Commit 429280f

Browse files
lesnik512claude
andcommitted
feat!: replace max_error_body_bytes with max_response_body_bytes
Status-agnostic, decoded-byte cap enforced at the non-streaming terminal via a streaming capped-accumulator (send(stream=True) + _read_capped). Branches on cap is None so the default path keeps plain send() and .elapsed. Construction validates >= 1. The old error-only param is removed (pre-1.0, no shim). BREAKING CHANGE: max_error_body_bytes is replaced by max_response_body_bytes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a22c65f commit 429280f

4 files changed

Lines changed: 205 additions & 20 deletions

File tree

src/httpware/client.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@
3232
)
3333

3434

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+
3544
def _parse_content_length(raw: str | None) -> int | None:
3645
"""Return a non-negative int Content-Length, or None for missing/garbage. Never raises."""
3746
if raw is None:
@@ -179,7 +188,7 @@ class AsyncClient:
179188
_decoders: tuple[ResponseDecoder, ...]
180189
_user_middleware: tuple[AsyncMiddleware, ...]
181190
_dispatch: AsyncNext
182-
_max_error_body_bytes: int | None
191+
_max_response_body_bytes: int | None
183192

184193
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
185194
self,
@@ -194,8 +203,9 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
194203
httpx2_client: httpx2.AsyncClient | None = None,
195204
decoders: Sequence[ResponseDecoder] | None = None,
196205
middleware: Sequence[AsyncMiddleware] = (),
197-
max_error_body_bytes: int | None = None,
206+
max_response_body_bytes: int | None = None,
198207
) -> None:
208+
_validate_max_response_body_bytes(max_response_body_bytes)
199209
if httpx2_client is not None:
200210
forwarded = {
201211
"base_url": base_url,
@@ -233,12 +243,20 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
233243
self._decoder_resolver = _DecoderResolver(self._decoders)
234244
self._user_middleware = tuple(middleware)
235245
self._dispatch = compose_async(self._user_middleware, self._terminal)
236-
self._max_error_body_bytes = max_error_body_bytes
246+
self._max_response_body_bytes = max_response_body_bytes
237247

238248
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
249+
cap = self._max_response_body_bytes
239250
try:
240251
async with _httpx2_exception_mapper():
241-
response = await self._httpx2_client.send(request)
252+
if cap is None:
253+
response = await self._httpx2_client.send(request)
254+
else:
255+
streaming = await self._httpx2_client.send(request, stream=True)
256+
try:
257+
response = await _read_capped_async(streaming, cap, request)
258+
finally:
259+
await streaming.aclose()
242260
except RuntimeError as exc:
243261
if self._httpx2_client.is_closed:
244262
raise TransportError(str(exc)) from exc
@@ -1100,12 +1118,12 @@ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signature
11001118

11011119
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
11021120
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1103-
if self._max_error_body_bytes is not None:
1121+
if self._max_response_body_bytes is not None:
11041122
content_length = _parse_content_length(response.headers.get("content-length"))
1105-
if content_length is not None and content_length > self._max_error_body_bytes:
1123+
if content_length is not None and content_length > self._max_response_body_bytes:
11061124
raise ResponseTooLargeError(
11071125
status_code=response.status_code,
1108-
limit=self._max_error_body_bytes,
1126+
limit=self._max_response_body_bytes,
11091127
content_length=content_length,
11101128
reason="declared",
11111129
)
@@ -1146,7 +1164,7 @@ class Client:
11461164
_decoders: tuple[ResponseDecoder, ...]
11471165
_user_middleware: tuple[Middleware, ...]
11481166
_dispatch: Next
1149-
_max_error_body_bytes: int | None
1167+
_max_response_body_bytes: int | None
11501168

11511169
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
11521170
self,
@@ -1161,8 +1179,9 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
11611179
httpx2_client: httpx2.Client | None = None,
11621180
decoders: Sequence[ResponseDecoder] | None = None,
11631181
middleware: Sequence[Middleware] = (),
1164-
max_error_body_bytes: int | None = None,
1182+
max_response_body_bytes: int | None = None,
11651183
) -> None:
1184+
_validate_max_response_body_bytes(max_response_body_bytes)
11661185
if httpx2_client is not None:
11671186
forwarded = {
11681187
"base_url": base_url,
@@ -1200,12 +1219,20 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
12001219
self._decoder_resolver = _DecoderResolver(self._decoders)
12011220
self._user_middleware = tuple(middleware)
12021221
self._dispatch = compose(self._user_middleware, self._terminal)
1203-
self._max_error_body_bytes = max_error_body_bytes
1222+
self._max_response_body_bytes = max_response_body_bytes
12041223

12051224
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
1225+
cap = self._max_response_body_bytes
12061226
try:
12071227
with _httpx2_exception_mapper_sync():
1208-
response = self._httpx2_client.send(request)
1228+
if cap is None:
1229+
response = self._httpx2_client.send(request)
1230+
else:
1231+
streaming = self._httpx2_client.send(request, stream=True)
1232+
try:
1233+
response = _read_capped(streaming, cap, request)
1234+
finally:
1235+
streaming.close()
12091236
except RuntimeError as exc:
12101237
if self._httpx2_client.is_closed:
12111238
raise TransportError(str(exc)) from exc
@@ -2089,12 +2116,12 @@ def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwa
20892116

20902117
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
20912118
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
2092-
if self._max_error_body_bytes is not None:
2119+
if self._max_response_body_bytes is not None:
20932120
content_length = _parse_content_length(response.headers.get("content-length"))
2094-
if content_length is not None and content_length > self._max_error_body_bytes:
2121+
if content_length is not None and content_length > self._max_response_body_bytes:
20952122
raise ResponseTooLargeError(
20962123
status_code=response.status_code,
2097-
limit=self._max_error_body_bytes,
2124+
limit=self._max_response_body_bytes,
20982125
content_length=content_length,
20992126
reason="declared",
21002127
)

tests/test_client_body_cap.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""max_response_body_bytes — non-streaming send() cap + construction validation.
2+
3+
Covers both clients: the terminal buffers under the cap and fails fast with
4+
ResponseTooLargeError when a response body (any status) exceeds it. stream()
5+
coverage lives in tests/test_client_stream*.py.
6+
"""
7+
8+
import gzip
9+
from collections.abc import AsyncIterator
10+
11+
import httpx2
12+
import pytest
13+
14+
from httpware import AsyncClient, Client
15+
from httpware.errors import ResponseTooLargeError
16+
17+
18+
def _sync(handler: object, cap: int | None) -> Client:
19+
return Client(
20+
httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), # ty: ignore[invalid-argument-type]
21+
max_response_body_bytes=cap,
22+
)
23+
24+
25+
def _async(handler: object, cap: int | None) -> AsyncClient:
26+
return AsyncClient(
27+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), # ty: ignore[invalid-argument-type]
28+
max_response_body_bytes=cap,
29+
)
30+
31+
32+
# ---- construction validation ----
33+
34+
35+
@pytest.mark.parametrize("bad", [0, -1])
36+
def test_async_rejects_cap_below_one(bad: int) -> None:
37+
with pytest.raises(ValueError, match="max_response_body_bytes must be >= 1"):
38+
AsyncClient(max_response_body_bytes=bad)
39+
40+
41+
@pytest.mark.parametrize("bad", [0, -1])
42+
def test_sync_rejects_cap_below_one(bad: int) -> None:
43+
with pytest.raises(ValueError, match="max_response_body_bytes must be >= 1"):
44+
Client(max_response_body_bytes=bad)
45+
46+
47+
# ---- sync send() ----
48+
49+
50+
def test_sync_send_within_cap_returns_response() -> None:
51+
body = b"hello world"
52+
53+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
54+
return httpx2.Response(200, content=body)
55+
56+
client = _sync(handler, 1000)
57+
request = client.build_request("GET", "https://example.test/x")
58+
assert client.send(request).content == body
59+
client.close()
60+
61+
62+
def test_sync_send_over_cap_declared_on_success() -> None:
63+
body = b"x" * 200
64+
65+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
66+
return httpx2.Response(200, content=body)
67+
68+
client = _sync(handler, 10)
69+
request = client.build_request("GET", "https://example.test/x")
70+
with pytest.raises(ResponseTooLargeError) as caught:
71+
client.send(request)
72+
assert caught.value.reason == "declared"
73+
assert caught.value.status_code == 200 # noqa: PLR2004 — status-agnostic: a 200 trips
74+
client.close()
75+
76+
77+
def test_sync_send_over_cap_streamed_gzip_bomb() -> None:
78+
raw = gzip.compress(b"A" * 100_000)
79+
80+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
81+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
82+
83+
client = _sync(handler, 1000)
84+
request = client.build_request("GET", "https://example.test/x")
85+
with pytest.raises(ResponseTooLargeError) as caught:
86+
client.send(request)
87+
assert caught.value.reason == "streamed"
88+
client.close()
89+
90+
91+
def test_sync_send_none_cap_unbounded() -> None:
92+
body = b"x" * 10_000
93+
94+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
95+
return httpx2.Response(200, content=body)
96+
97+
client = _sync(handler, None)
98+
request = client.build_request("GET", "https://example.test/x")
99+
assert client.send(request).content == body
100+
client.close()
101+
102+
103+
# ---- async send() ----
104+
105+
106+
async def test_async_send_within_cap_returns_response() -> None:
107+
body = b"hello world"
108+
109+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
110+
return httpx2.Response(200, content=body)
111+
112+
client = _async(handler, 1000)
113+
request = client.build_request("GET", "https://example.test/x")
114+
assert (await client.send(request)).content == body
115+
await client.aclose()
116+
117+
118+
async def test_async_send_over_cap_declared() -> None:
119+
body = b"x" * 200
120+
121+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
122+
return httpx2.Response(200, content=body)
123+
124+
client = _async(handler, 10)
125+
request = client.build_request("GET", "https://example.test/x")
126+
with pytest.raises(ResponseTooLargeError) as caught:
127+
await client.send(request)
128+
assert caught.value.reason == "declared"
129+
await client.aclose()
130+
131+
132+
async def test_async_send_over_cap_streamed_chunked() -> None:
133+
async def body() -> AsyncIterator[bytes]:
134+
yield b"a" * 50
135+
yield b"b" * 50
136+
137+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
138+
return httpx2.Response(200, content=body())
139+
140+
client = _async(handler, 70)
141+
request = client.build_request("GET", "https://example.test/x")
142+
with pytest.raises(ResponseTooLargeError) as caught:
143+
await client.send(request)
144+
assert caught.value.reason == "streamed"
145+
assert caught.value.content_length is None
146+
await client.aclose()
147+
148+
149+
async def test_async_send_none_cap_unbounded() -> None:
150+
body = b"x" * 10_000
151+
152+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
153+
return httpx2.Response(200, content=body)
154+
155+
client = _async(handler, None)
156+
request = client.build_request("GET", "https://example.test/x")
157+
assert (await client.send(request)).content == body
158+
await client.aclose()

tests/test_client_stream.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,12 +347,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
347347
return httpx2.Response(500, content=body)
348348

349349
client = AsyncClient(
350-
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_error_body_bytes=10
350+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_response_body_bytes=10
351351
)
352352
with pytest.raises(ResponseTooLargeError) as caught:
353353
async with client.stream("GET", "https://example.test/x"):
354354
pytest.fail("unreachable")
355-
assert caught.value.limit == 10 # noqa: PLR2004 — mirrors max_error_body_bytes above
355+
assert caught.value.limit == 10 # noqa: PLR2004 — mirrors max_response_body_bytes above
356356
assert caught.value.content_length == 200 # noqa: PLR2004 — len(body) above
357357
await client.aclose()
358358

@@ -364,7 +364,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
364364
return httpx2.Response(404, content=body)
365365

366366
client = AsyncClient(
367-
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_error_body_bytes=1000
367+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_response_body_bytes=1000
368368
)
369369
with pytest.raises(NotFoundError) as caught:
370370
async with client.stream("GET", "https://example.test/x"):

tests/test_client_stream_sync.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,10 @@ def test_stream_raises_response_too_large_when_over_cap_sync() -> None:
314314
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
315315
return httpx2.Response(500, content=body)
316316

317-
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_error_body_bytes=10)
317+
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_response_body_bytes=10)
318318
with pytest.raises(ResponseTooLargeError) as caught, client.stream("GET", "https://example.test/x"):
319319
pytest.fail("unreachable")
320-
assert caught.value.limit == 10 # noqa: PLR2004 — mirrors max_error_body_bytes above
320+
assert caught.value.limit == 10 # noqa: PLR2004 — mirrors max_response_body_bytes above
321321
assert caught.value.content_length == 200 # noqa: PLR2004 — len(body) above
322322
client.close()
323323

@@ -328,7 +328,7 @@ def test_stream_reads_error_body_when_under_cap_sync() -> None:
328328
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
329329
return httpx2.Response(404, content=body)
330330

331-
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_error_body_bytes=1000)
331+
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_response_body_bytes=1000)
332332
with pytest.raises(NotFoundError) as caught, client.stream("GET", "https://example.test/x"):
333333
pytest.fail("unreachable")
334334
assert caught.value.response.content == body

0 commit comments

Comments
 (0)