Skip to content

Commit a22c65f

Browse files
lesnik512claude
andcommitted
feat: add shared _read_capped streaming accumulator
_read_capped / _read_capped_async wrap the pure core with the Content-Length early-reject and the buffered Response rebuild; _safe_extensions drops the stale network_stream. Caller owns the stream lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbbe329 commit a22c65f

2 files changed

Lines changed: 241 additions & 1 deletion

File tree

src/httpware/client.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import contextlib
44
import typing
5-
from collections.abc import AsyncIterator, Iterator, Sequence
5+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
66
from http import HTTPStatus
77

88
import httpx2
@@ -65,6 +65,69 @@ def _accumulate_capped(chunks: typing.Iterable[bytes], cap: int) -> bytes:
6565
return bytes(buf)
6666

6767

68+
def _safe_extensions(extensions: Mapping[str, typing.Any]) -> dict[str, typing.Any]:
69+
"""Copy response extensions, dropping the now-stale `network_stream`.
70+
71+
The rebuilt buffered Response never touches its network stream, so carrying a
72+
consumed/closed one wholesale is sloppy. `http_version`/`reason_phrase` and
73+
any other keys are preserved.
74+
"""
75+
return {key: value for key, value in extensions.items() if key != "network_stream"}
76+
77+
78+
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
79+
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
80+
81+
Raises `ResponseTooLargeError` (reason="declared") if the declared
82+
Content-Length already exceeds `cap` — before any byte is read — and
83+
(reason="streamed") if the decoded body crosses `cap` mid-read. Does not
84+
close `response`; the caller owns the stream lifecycle.
85+
"""
86+
content_length = _parse_content_length(response.headers.get("content-length"))
87+
if content_length is not None and content_length > cap:
88+
raise ResponseTooLargeError(
89+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
90+
)
91+
try:
92+
content = _accumulate_capped(response.iter_bytes(), cap)
93+
except _CapExceeded:
94+
raise ResponseTooLargeError(
95+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
96+
) from None
97+
return httpx2.Response(
98+
status_code=response.status_code,
99+
headers=response.headers,
100+
content=content,
101+
request=request,
102+
extensions=_safe_extensions(response.extensions),
103+
history=response.history,
104+
)
105+
106+
107+
async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
108+
"""Async mirror of `_read_capped` (counts decoded bytes from `aiter_bytes`)."""
109+
content_length = _parse_content_length(response.headers.get("content-length"))
110+
if content_length is not None and content_length > cap:
111+
raise ResponseTooLargeError(
112+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
113+
)
114+
buf = bytearray()
115+
async for chunk in response.aiter_bytes():
116+
buf += chunk
117+
if len(buf) > cap:
118+
raise ResponseTooLargeError(
119+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
120+
)
121+
return httpx2.Response(
122+
status_code=response.status_code,
123+
headers=response.headers,
124+
content=bytes(buf),
125+
request=request,
126+
extensions=_safe_extensions(response.extensions),
127+
history=response.history,
128+
)
129+
130+
68131
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
69132
"""Construct the default decoder tuple based on installed extras.
70133

tests/test_capped_read.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Unit tests for the shared _read_capped wrappers (sync + async).
2+
3+
Drive real streaming responses through MockTransport, then hand the streaming
4+
Response to _read_capped / _read_capped_async directly — exercising the
5+
Content-Length early reject, the decoded-byte accumulator, the rebuilt Response,
6+
and extension sanitisation, independent of client wiring.
7+
"""
8+
9+
import gzip
10+
from collections.abc import AsyncIterator
11+
12+
import httpx2
13+
import pytest
14+
15+
from httpware.client import _read_capped, _read_capped_async
16+
from httpware.errors import ResponseTooLargeError
17+
18+
19+
def _sync_stream(handler: object) -> tuple[httpx2.Client, httpx2.Response]:
20+
client = httpx2.Client(transport=httpx2.MockTransport(handler)) # ty: ignore[invalid-argument-type]
21+
request = client.build_request("GET", "https://example.test/x")
22+
return client, client.send(request, stream=True)
23+
24+
25+
async def _async_stream(handler: object) -> tuple[httpx2.AsyncClient, httpx2.Response]:
26+
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) # ty: ignore[invalid-argument-type]
27+
request = client.build_request("GET", "https://example.test/x")
28+
return client, await client.send(request, stream=True)
29+
30+
31+
# ---- sync ----
32+
33+
34+
def test_read_capped_returns_buffered_response_within_cap() -> None:
35+
body = b"hello world"
36+
37+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
38+
return httpx2.Response(200, content=body)
39+
40+
client, resp = _sync_stream(handler)
41+
try:
42+
out = _read_capped(resp, 1000, resp.request)
43+
assert out.content == body
44+
assert out.status_code == 200 # noqa: PLR2004 — mirrors handler
45+
assert "network_stream" not in out.extensions
46+
finally:
47+
resp.close()
48+
client.close()
49+
50+
51+
def test_read_capped_declared_content_length_over_cap() -> None:
52+
body = b"x" * 200
53+
54+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
55+
return httpx2.Response(500, content=body)
56+
57+
client, resp = _sync_stream(handler)
58+
try:
59+
with pytest.raises(ResponseTooLargeError) as caught:
60+
_read_capped(resp, 10, resp.request)
61+
assert caught.value.reason == "declared"
62+
assert caught.value.content_length == 200 # noqa: PLR2004 — len(body)
63+
assert caught.value.limit == 10 # noqa: PLR2004 — cap above
64+
finally:
65+
resp.close()
66+
client.close()
67+
68+
69+
def test_read_capped_streamed_over_cap_chunked_no_content_length() -> None:
70+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
71+
return httpx2.Response(200, content=(c for c in (b"a" * 50, b"b" * 50)))
72+
73+
client, resp = _sync_stream(handler)
74+
try:
75+
with pytest.raises(ResponseTooLargeError) as caught:
76+
_read_capped(resp, 10, resp.request)
77+
assert caught.value.reason == "streamed"
78+
assert caught.value.content_length is None
79+
finally:
80+
resp.close()
81+
client.close()
82+
83+
84+
def test_read_capped_gzip_bomb_trips_on_decoded_bytes() -> None:
85+
raw = gzip.compress(b"A" * 100_000)
86+
87+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
88+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
89+
90+
client, resp = _sync_stream(handler)
91+
try:
92+
with pytest.raises(ResponseTooLargeError) as caught:
93+
_read_capped(resp, 1000, resp.request)
94+
assert caught.value.reason == "streamed" # compressed CL (small) passed; decoded tripped
95+
finally:
96+
resp.close()
97+
client.close()
98+
99+
100+
def test_read_capped_exact_cap_passes() -> None:
101+
body = b"x" * 10
102+
103+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
104+
return httpx2.Response(200, content=body)
105+
106+
client, resp = _sync_stream(handler)
107+
try:
108+
assert _read_capped(resp, 10, resp.request).content == body
109+
finally:
110+
resp.close()
111+
client.close()
112+
113+
114+
def test_read_capped_empty_body_passes() -> None:
115+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
116+
return httpx2.Response(204)
117+
118+
client, resp = _sync_stream(handler)
119+
try:
120+
assert _read_capped(resp, 1, resp.request).content == b""
121+
finally:
122+
resp.close()
123+
client.close()
124+
125+
126+
# ---- async ----
127+
128+
129+
async def test_read_capped_async_returns_buffered_response_within_cap() -> None:
130+
body = b"hello world"
131+
132+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
133+
return httpx2.Response(200, content=body)
134+
135+
client, resp = await _async_stream(handler)
136+
try:
137+
out = await _read_capped_async(resp, 1000, resp.request)
138+
assert out.content == body
139+
assert "network_stream" not in out.extensions
140+
finally:
141+
await resp.aclose()
142+
await client.aclose()
143+
144+
145+
async def test_read_capped_async_declared_over_cap() -> None:
146+
body = b"x" * 200
147+
148+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
149+
return httpx2.Response(500, content=body)
150+
151+
client, resp = await _async_stream(handler)
152+
try:
153+
with pytest.raises(ResponseTooLargeError) as caught:
154+
await _read_capped_async(resp, 10, resp.request)
155+
assert caught.value.reason == "declared"
156+
assert caught.value.content_length == 200 # noqa: PLR2004 — len(body)
157+
finally:
158+
await resp.aclose()
159+
await client.aclose()
160+
161+
162+
async def test_read_capped_async_streamed_over_cap() -> None:
163+
async def body() -> AsyncIterator[bytes]:
164+
yield b"a" * 50
165+
yield b"b" * 50
166+
167+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
168+
return httpx2.Response(200, content=body())
169+
170+
client, resp = await _async_stream(handler)
171+
try:
172+
with pytest.raises(ResponseTooLargeError) as caught:
173+
await _read_capped_async(resp, 70, resp.request) # trips on the second 50-byte chunk
174+
assert caught.value.reason == "streamed"
175+
finally:
176+
await resp.aclose()
177+
await client.aclose()

0 commit comments

Comments
 (0)