Skip to content

Commit 60f7072

Browse files
authored
refactor(body_cap): share non-iteration logic between sync/async
Extracts two byte-identical blocks from _read_capped/_read_capped_async into shared functions: _reject_if_declared_length_exceeds_cap and _build_buffered_response. The byte-accumulation loop stays duplicated -- sync/async genuinely differ there. Fix folded in from final review: no test asserted content_length on the "streamed" reason when a declared length is actually present (gzip-bomb scenario). Added the missing assertion plus a new async mirror test, also closing a pre-existing sync/async test-parity gap. Design: planning/changes/2026-07-13.06-body-cap-shared-non-iteration-logic.md
1 parent 2d0d34d commit 60f7072

3 files changed

Lines changed: 117 additions & 26 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
summary: Share the byte-identical declared-length-reject and Response-reconstruction blocks between _read_capped and _read_capped_async.
3+
---
4+
5+
# Change: Share body_cap's non-iteration logic between sync/async
6+
7+
**Lane:** lightweight — single file, no new file, no public-API change, no
8+
test changes (existing suite is the parity net).
9+
10+
## Goal
11+
12+
`_read_capped` and `_read_capped_async` (`src/httpware/_internal/body_cap.py`)
13+
hand-duplicate two byte-identical blocks: the declared-`Content-Length`
14+
early-reject, and the final `httpx2.Response` reconstruction. Only the
15+
actual byte-accumulation loop between them genuinely differs (sync uses
16+
`_accumulate_capped`+`_CapExceeded`; async inlines its own loop) and stays
17+
duplicated — same judgment already applied to every other sync/async split
18+
this session. No behavior change.
19+
20+
## Approach
21+
22+
Two new module-level functions, local to `body_cap.py` (used only by its
23+
own two functions — same precedent as every other local-scope decision
24+
this session):
25+
26+
```python
27+
def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int) -> int | None:
28+
"""Return the parsed Content-Length, raising ResponseTooLargeError(reason="declared") if it already exceeds cap."""
29+
content_length = _parse_content_length(response.headers.get("content-length"))
30+
if content_length is not None and content_length > cap:
31+
raise ResponseTooLargeError(
32+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
33+
)
34+
return content_length
35+
36+
37+
def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response:
38+
"""Rebuild a buffered Response from the original streaming `response` and the final decoded `content`."""
39+
return httpx2.Response(
40+
status_code=response.status_code,
41+
headers=_buffered_headers(response.headers),
42+
content=content,
43+
request=request,
44+
extensions=_safe_extensions(response.extensions),
45+
history=response.history,
46+
)
47+
```
48+
49+
`content_length` is returned (not just checked) because both call sites
50+
reuse it in their second (`reason="streamed"`) raise. Both `_read_capped`
51+
and `_read_capped_async` call these two functions in place of their inline
52+
blocks; the accumulation loop between them is untouched.
53+
54+
## Files
55+
56+
- `src/httpware/_internal/body_cap.py` — add the two functions; replace
57+
the duplicated declared-reject block and the duplicated
58+
`httpx2.Response(...)` reconstruction in both `_read_capped` and
59+
`_read_capped_async` with calls to them.
60+
- No test file changes — `tests/test_body_cap.py` already exercises both
61+
extraction targets across both sync and async (declared-over-cap,
62+
streamed-over-cap using the returned `content_length`, within-cap /
63+
exact-cap / empty-body using the reconstruction path).
64+
65+
## Verification
66+
67+
- [ ] `just test` — full suite green, 100% coverage maintained, same test
68+
count (pure refactor, no new/removed tests).
69+
- [ ] `just lint` — clean.
70+
- [ ] Grep `body_cap.py` to confirm the declared-reject block and the
71+
`httpx2.Response(...)` construction each appear exactly once (inside
72+
the two new functions), not inline in `_read_capped`/
73+
`_read_capped_async`.

‎src/httpware/_internal/body_cap.py‎

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,28 @@ def _response_has_body(method: str, status_code: int) -> bool:
8787
return method.upper() != "HEAD" and status_code not in _BODILESS_STATUS
8888

8989

90+
def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int) -> int | None:
91+
"""Return the parsed Content-Length, raising ResponseTooLargeError(reason="declared") if it already exceeds cap."""
92+
content_length = _parse_content_length(response.headers.get("content-length"))
93+
if content_length is not None and content_length > cap:
94+
raise ResponseTooLargeError(
95+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
96+
)
97+
return content_length
98+
99+
100+
def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response:
101+
"""Rebuild a buffered Response from the original streaming `response` and the final decoded `content`."""
102+
return httpx2.Response(
103+
status_code=response.status_code,
104+
headers=_buffered_headers(response.headers),
105+
content=content,
106+
request=request,
107+
extensions=_safe_extensions(response.extensions),
108+
history=response.history,
109+
)
110+
111+
90112
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
91113
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
92114
@@ -98,49 +120,27 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
98120
if not _response_has_body(request.method, response.status_code):
99121
response.read() # empty body; preserve the original response (and its headers)
100122
return response
101-
content_length = _parse_content_length(response.headers.get("content-length"))
102-
if content_length is not None and content_length > cap:
103-
raise ResponseTooLargeError(
104-
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
105-
)
123+
content_length = _reject_if_declared_length_exceeds_cap(response, cap)
106124
try:
107125
content = _accumulate_capped(response.iter_bytes(), cap)
108126
except _CapExceeded:
109127
raise ResponseTooLargeError(
110128
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
111129
) from None
112-
return httpx2.Response(
113-
status_code=response.status_code,
114-
headers=_buffered_headers(response.headers),
115-
content=content,
116-
request=request,
117-
extensions=_safe_extensions(response.extensions),
118-
history=response.history,
119-
)
130+
return _build_buffered_response(response, content, request)
120131

121132

122133
async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
123134
"""Async mirror of `_read_capped` (counts decoded bytes from `aiter_bytes`)."""
124135
if not _response_has_body(request.method, response.status_code):
125136
await response.aread() # empty body; preserve the original response (and its headers)
126137
return response
127-
content_length = _parse_content_length(response.headers.get("content-length"))
128-
if content_length is not None and content_length > cap:
129-
raise ResponseTooLargeError(
130-
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
131-
)
138+
content_length = _reject_if_declared_length_exceeds_cap(response, cap)
132139
buf = bytearray()
133140
async for chunk in response.aiter_bytes():
134141
buf += chunk
135142
if len(buf) > cap:
136143
raise ResponseTooLargeError(
137144
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
138145
)
139-
return httpx2.Response(
140-
status_code=response.status_code,
141-
headers=_buffered_headers(response.headers),
142-
content=bytes(buf),
143-
request=request,
144-
extensions=_safe_extensions(response.extensions),
145-
history=response.history,
146-
)
146+
return _build_buffered_response(response, bytes(buf), request)

‎tests/test_body_cap.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
125125
with pytest.raises(ResponseTooLargeError) as caught:
126126
_read_capped(resp, 1000, resp.request)
127127
assert caught.value.reason == "streamed" # compressed CL (small) passed; decoded tripped
128+
assert caught.value.content_length == len(raw) # the declared (compressed) length, threaded through
128129
finally:
129130
resp.close()
130131
client.close()
@@ -192,6 +193,23 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
192193
await client.aclose()
193194

194195

196+
async def test_read_capped_async_gzip_bomb_trips_on_decoded_bytes() -> None:
197+
raw = gzip.compress(b"A" * 100_000)
198+
199+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
200+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
201+
202+
client, resp = await _async_stream(handler)
203+
try:
204+
with pytest.raises(ResponseTooLargeError) as caught:
205+
await _read_capped_async(resp, 1000, resp.request)
206+
assert caught.value.reason == "streamed" # compressed CL (small) passed; decoded tripped
207+
assert caught.value.content_length == len(raw) # the declared (compressed) length, threaded through
208+
finally:
209+
await resp.aclose()
210+
await client.aclose()
211+
212+
195213
async def test_read_capped_async_head_with_large_declared_length_not_rejected() -> None:
196214
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
197215
return httpx2.Response(200, headers={"content-length": "50000000"})

0 commit comments

Comments
 (0)