Skip to content

Commit 9974c95

Browse files
lesnik512claude
andcommitted
fix: correct capped-rebuild header handling + skip bodiless responses
Two bugs in the capped read path found in review of #78: - The rebuilt Response kept content-encoding while holding already-decoded content, so httpx2 re-decompressed and crashed on every compressed body under the cap. Strip content-encoding / transfer-encoding / (compressed) content-length via _buffered_headers; httpx2 recomputes content-length. - A bodiless response (HEAD / 204 / 304) with a large declared Content-Length was falsely rejected. _response_has_body short-circuits these: read the empty body and return the original response unchanged, preserving its headers (HEAD legitimately echoes the entity length). Adds within-cap compressed-body and bodiless regression tests at the _read_capped, send(), and stream() levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d450424 commit 9974c95

6 files changed

Lines changed: 189 additions & 6 deletions

File tree

architecture/client.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ The cap bounds memory that httpware buffers on your behalf, at two sites:
4242

4343
The declared `Content-Length` is used only as an *early reject* (if even the compressed size already exceeds the cap, fail before reading a byte); it is never an early accept, so the accumulator always runs — chunked and bomb bodies are caught, not waved through. `ResponseTooLargeError.reason` is `"declared"` or `"streamed"` accordingly. Entirely public httpx2 API — no private access.
4444

45+
**Bodiless responses bypass the cap.** Responses that carry no message body — to a `HEAD` request, or with status `204`/`304` — buffer nothing, so the cap never applies to them even when they declare a large `Content-Length` (`HEAD` legitimately echoes the entity length). These are returned unchanged, preserving their original headers.
46+
47+
**Rebuilt headers.** The accumulator yields the *decoded* body, so the rebuilt Response drops the wire-encoding headers (`Content-Encoding`, `Transfer-Encoding`, and the now-incorrect compressed `Content-Length`); httpx2 recomputes `Content-Length` from the buffered content. Carrying `Content-Encoding` forward would make httpx2 re-decode already-decoded bytes and raise.
48+
4549
**Caveat:** on the capped path the buffered response is rebuilt via the public `httpx2.Response(content=...)` constructor, which does not carry `.elapsed` (httpx2 only sets it on its own buffered `send()`). Clients that set a cap and read `response.elapsed` will find it absent; the `None`-cap fast path preserves it.

src/httpware/client.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,32 @@ def _safe_extensions(extensions: Mapping[str, typing.Any]) -> dict[str, typing.A
8484
return {key: value for key, value in extensions.items() if key != "network_stream"}
8585

8686

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+
87113
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
88114
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
89115
@@ -92,6 +118,9 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
92118
(reason="streamed") if the decoded body crosses `cap` mid-read. Does not
93119
close `response`; the caller owns the stream lifecycle.
94120
"""
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
95124
content_length = _parse_content_length(response.headers.get("content-length"))
96125
if content_length is not None and content_length > cap:
97126
raise ResponseTooLargeError(
@@ -105,7 +134,7 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
105134
) from None
106135
return httpx2.Response(
107136
status_code=response.status_code,
108-
headers=response.headers,
137+
headers=_buffered_headers(response.headers),
109138
content=content,
110139
request=request,
111140
extensions=_safe_extensions(response.extensions),
@@ -115,6 +144,9 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
115144

116145
async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
117146
"""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
118150
content_length = _parse_content_length(response.headers.get("content-length"))
119151
if content_length is not None and content_length > cap:
120152
raise ResponseTooLargeError(
@@ -129,7 +161,7 @@ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx
129161
)
130162
return httpx2.Response(
131163
status_code=response.status_code,
132-
headers=response.headers,
164+
headers=_buffered_headers(response.headers),
133165
content=bytes(buf),
134166
request=request,
135167
extensions=_safe_extensions(response.extensions),

tests/test_capped_read.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@
1616
from httpware.errors import ResponseTooLargeError
1717

1818

19-
def _sync_stream(handler: object) -> tuple[httpx2.Client, httpx2.Response]:
19+
def _sync_stream(handler: object, method: str = "GET") -> tuple[httpx2.Client, httpx2.Response]:
2020
client = httpx2.Client(transport=httpx2.MockTransport(handler)) # ty: ignore[invalid-argument-type]
21-
request = client.build_request("GET", "https://example.test/x")
21+
request = client.build_request(method, "https://example.test/x")
2222
return client, client.send(request, stream=True)
2323

2424

25-
async def _async_stream(handler: object) -> tuple[httpx2.AsyncClient, httpx2.Response]:
25+
async def _async_stream(handler: object, method: str = "GET") -> tuple[httpx2.AsyncClient, httpx2.Response]:
2626
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) # ty: ignore[invalid-argument-type]
27-
request = client.build_request("GET", "https://example.test/x")
27+
request = client.build_request(method, "https://example.test/x")
2828
return client, await client.send(request, stream=True)
2929

3030

@@ -81,6 +81,39 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
8181
client.close()
8282

8383

84+
def test_read_capped_within_cap_gzip_returns_decoded_content() -> None:
85+
# Regression: rebuilt Response must not re-decompress already-decoded content.
86+
raw = gzip.compress(b"A" * 500)
87+
88+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
89+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
90+
91+
client, resp = _sync_stream(handler)
92+
try:
93+
out = _read_capped(resp, 1_000_000, resp.request)
94+
assert out.content == b"A" * 500 # decoded, not re-gzipped/crashed
95+
assert "content-encoding" not in out.headers # stale wire header dropped
96+
assert out.headers["content-length"] == "500" # recomputed from decoded content
97+
finally:
98+
resp.close()
99+
client.close()
100+
101+
102+
def test_read_capped_head_with_large_declared_length_not_rejected() -> None:
103+
# Regression: a bodiless HEAD response buffers nothing and must not trip the cap.
104+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
105+
return httpx2.Response(200, headers={"content-length": "50000000"})
106+
107+
client, resp = _sync_stream(handler, method="HEAD")
108+
try:
109+
out = _read_capped(resp, 1000, resp.request)
110+
assert out.content == b""
111+
assert out.headers["content-length"] == "50000000" # entity length preserved for HEAD
112+
finally:
113+
resp.close()
114+
client.close()
115+
116+
84117
def test_read_capped_gzip_bomb_trips_on_decoded_bytes() -> None:
85118
raw = gzip.compress(b"A" * 100_000)
86119

@@ -142,6 +175,37 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
142175
await client.aclose()
143176

144177

178+
async def test_read_capped_async_within_cap_gzip_returns_decoded_content() -> None:
179+
raw = gzip.compress(b"A" * 500)
180+
181+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
182+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
183+
184+
client, resp = await _async_stream(handler)
185+
try:
186+
out = await _read_capped_async(resp, 1_000_000, resp.request)
187+
assert out.content == b"A" * 500
188+
assert "content-encoding" not in out.headers
189+
assert out.headers["content-length"] == "500"
190+
finally:
191+
await resp.aclose()
192+
await client.aclose()
193+
194+
195+
async def test_read_capped_async_head_with_large_declared_length_not_rejected() -> None:
196+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
197+
return httpx2.Response(200, headers={"content-length": "50000000"})
198+
199+
client, resp = await _async_stream(handler, method="HEAD")
200+
try:
201+
out = await _read_capped_async(resp, 1000, resp.request)
202+
assert out.content == b""
203+
assert out.headers["content-length"] == "50000000"
204+
finally:
205+
await resp.aclose()
206+
await client.aclose()
207+
208+
145209
async def test_read_capped_async_declared_over_cap() -> None:
146210
body = b"x" * 200
147211

tests/test_client_body_cap.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
8888
client.close()
8989

9090

91+
def test_sync_send_within_cap_gzip_returns_decoded() -> None:
92+
raw = gzip.compress(b"A" * 500)
93+
94+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
95+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
96+
97+
client = _sync(handler, 1_000_000)
98+
request = client.build_request("GET", "https://example.test/x")
99+
assert client.send(request).content == b"A" * 500 # not re-decompressed/crashed
100+
client.close()
101+
102+
103+
def test_sync_head_large_declared_length_not_rejected() -> None:
104+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
105+
return httpx2.Response(200, headers={"content-length": "50000000"})
106+
107+
client = _sync(handler, 1000)
108+
request = client.build_request("HEAD", "https://example.test/x")
109+
response = client.send(request)
110+
assert response.content == b""
111+
assert response.headers["content-length"] == "50000000"
112+
client.close()
113+
114+
91115
def test_sync_send_none_cap_unbounded() -> None:
92116
body = b"x" * 10_000
93117

@@ -146,6 +170,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
146170
await client.aclose()
147171

148172

173+
async def test_async_send_within_cap_gzip_returns_decoded() -> None:
174+
raw = gzip.compress(b"A" * 500)
175+
176+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
177+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
178+
179+
client = _async(handler, 1_000_000)
180+
request = client.build_request("GET", "https://example.test/x")
181+
assert (await client.send(request)).content == b"A" * 500
182+
await client.aclose()
183+
184+
185+
async def test_async_head_large_declared_length_not_rejected() -> None:
186+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
187+
return httpx2.Response(200, headers={"content-length": "50000000"})
188+
189+
client = _async(handler, 1000)
190+
request = client.build_request("HEAD", "https://example.test/x")
191+
response = await client.send(request)
192+
assert response.content == b""
193+
assert response.headers["content-length"] == "50000000"
194+
await client.aclose()
195+
196+
149197
async def test_async_send_none_cap_unbounded() -> None:
150198
body = b"x" * 10_000
151199

tests/test_client_stream.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
406406
await client.aclose()
407407

408408

409+
async def test_stream_error_pre_read_within_cap_gzip_decoded() -> None:
410+
import gzip # noqa: PLC0415 — local to this regression test
411+
412+
raw = gzip.compress(b"boom" * 50)
413+
414+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
415+
return httpx2.Response(500, headers={"content-encoding": "gzip"}, content=raw)
416+
417+
client = AsyncClient(
418+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), max_response_body_bytes=1_000_000
419+
)
420+
with pytest.raises(InternalServerError) as caught:
421+
async with client.stream("GET", "https://example.test/x"):
422+
pytest.fail("unreachable")
423+
assert caught.value.response.content == b"boom" * 50 # decoded, not re-decompressed
424+
await client.aclose()
425+
426+
409427
async def test_stream_user_driven_success_body_not_capped() -> None:
410428
body = b"x" * 100_000
411429

tests/test_client_stream_sync.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,23 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
360360
client.close()
361361

362362

363+
def test_stream_error_pre_read_within_cap_gzip_decoded_sync() -> None:
364+
import gzip # noqa: PLC0415 — local to this regression test
365+
366+
raw = gzip.compress(b"boom" * 50)
367+
368+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
369+
return httpx2.Response(500, headers={"content-encoding": "gzip"}, content=raw)
370+
371+
client = Client(
372+
httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_response_body_bytes=1_000_000
373+
)
374+
with pytest.raises(InternalServerError) as caught, client.stream("GET", "https://example.test/x"):
375+
pytest.fail("unreachable")
376+
assert caught.value.response.content == b"boom" * 50
377+
client.close()
378+
379+
363380
def test_stream_user_driven_success_body_not_capped_sync() -> None:
364381
body = b"x" * 100_000
365382

0 commit comments

Comments
 (0)