Skip to content

Commit 6aea224

Browse files
lesnik512claude
andcommitted
feat: bound stream() error pre-read via _read_capped
Both stream() error branches route the 4xx/5xx pre-read through the shared accumulator when a cap is set, so chunked/compression-bombed error bodies are caught instead of read unbounded; exc.response.content stays populated. User-driven success streaming is never capped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 429280f commit 6aea224

3 files changed

Lines changed: 73 additions & 22 deletions

File tree

src/httpware/client.py

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,17 +1118,13 @@ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signature
11181118

11191119
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
11201120
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1121-
if self._max_response_body_bytes is not None:
1122-
content_length = _parse_content_length(response.headers.get("content-length"))
1123-
if content_length is not None and content_length > self._max_response_body_bytes:
1124-
raise ResponseTooLargeError(
1125-
status_code=response.status_code,
1126-
limit=self._max_response_body_bytes,
1127-
content_length=content_length,
1128-
reason="declared",
1129-
)
1130-
await response.aread() # pre-read body so exc.response.content works
1131-
_raise_on_status_error(response)
1121+
cap = self._max_response_body_bytes
1122+
if cap is None:
1123+
await response.aread() # pre-read body so exc.response.content works
1124+
_raise_on_status_error(response)
1125+
else:
1126+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
1127+
_raise_on_status_error(await _read_capped_async(response, cap, response.request))
11321128
yield response
11331129

11341130
async def __aenter__(self) -> typing.Self:
@@ -2116,15 +2112,11 @@ def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwa
21162112

21172113
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
21182114
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
2119-
if self._max_response_body_bytes is not None:
2120-
content_length = _parse_content_length(response.headers.get("content-length"))
2121-
if content_length is not None and content_length > self._max_response_body_bytes:
2122-
raise ResponseTooLargeError(
2123-
status_code=response.status_code,
2124-
limit=self._max_response_body_bytes,
2125-
content_length=content_length,
2126-
reason="declared",
2127-
)
2128-
response.read() # pre-read body so exc.response.content works
2129-
_raise_on_status_error(response)
2115+
cap = self._max_response_body_bytes
2116+
if cap is None:
2117+
response.read() # pre-read body so exc.response.content works
2118+
_raise_on_status_error(response)
2119+
else:
2120+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
2121+
_raise_on_status_error(_read_capped(response, cap, response.request))
21302122
yield response

tests/test_client_stream.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,40 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
387387
await client.aclose()
388388

389389

390+
async def test_stream_error_pre_read_streamed_over_cap() -> None:
391+
async def body() -> typing.AsyncIterator[bytes]:
392+
yield b"a" * 50
393+
yield b"b" * 50
394+
395+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
396+
return httpx2.Response(500, content=body()) # chunked: no Content-Length
397+
398+
client = AsyncClient(
399+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_response_body_bytes=70
400+
)
401+
with pytest.raises(ResponseTooLargeError) as caught:
402+
async with client.stream("GET", "https://example.test/x"):
403+
pytest.fail("unreachable")
404+
assert caught.value.reason == "streamed"
405+
assert caught.value.content_length is None
406+
await client.aclose()
407+
408+
409+
async def test_stream_user_driven_success_body_not_capped() -> None:
410+
body = b"x" * 100_000
411+
412+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
413+
return httpx2.Response(200, content=body)
414+
415+
client = AsyncClient(
416+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_response_body_bytes=10
417+
)
418+
async with client.stream("GET", "https://example.test/x") as response:
419+
chunks = [chunk async for chunk in response.aiter_bytes()]
420+
assert b"".join(chunks) == body # user-driven streaming is never capped
421+
await client.aclose()
422+
423+
390424
@pytest.mark.parametrize(
391425
("raw", "expected"),
392426
[(None, None), ("123", 123), ("abc", None), ("-5", None), ("0", 0)],

tests/test_client_stream_sync.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,28 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
346346
pytest.fail("unreachable")
347347
assert caught.value.response.content == body
348348
client.close()
349+
350+
351+
def test_stream_error_pre_read_streamed_over_cap_sync() -> None:
352+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
353+
return httpx2.Response(500, content=(c for c in (b"a" * 50, b"b" * 50))) # chunked: no Content-Length
354+
355+
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_response_body_bytes=70)
356+
with pytest.raises(ResponseTooLargeError) as caught, client.stream("GET", "https://example.test/x"):
357+
pytest.fail("unreachable")
358+
assert caught.value.reason == "streamed"
359+
assert caught.value.content_length is None
360+
client.close()
361+
362+
363+
def test_stream_user_driven_success_body_not_capped_sync() -> None:
364+
body = b"x" * 100_000
365+
366+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
367+
return httpx2.Response(200, content=body)
368+
369+
client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_response_body_bytes=10)
370+
with client.stream("GET", "https://example.test/x") as response:
371+
chunks = list(response.iter_bytes())
372+
assert b"".join(chunks) == body # user-driven streaming is never capped
373+
client.close()

0 commit comments

Comments
 (0)