Skip to content

Commit a7cfa3a

Browse files
authored
perf: count capped bytes on the stream for unencoded bodies (#192)
* perf: count capped bytes on the stream for unencoded bodies For a response with no Content-Encoding, the body cap now wraps response.stream in a stream that counts bytes and raises ResponseTooLargeError once the count passes the cap, and httpx2 reads the response with its own read()/aread(). The capped response is the object httpx2 built, so its headers, extensions and num_bytes_downloaded match an uncapped one. Encoded bodies keep the rebuild path. Closes #170 * fix: close the response when a capped stream read raises httpx2 2.0.0 closes a response only when its body is read to the end, so an over-cap read left it open on the floor version.
1 parent 6f32ca9 commit a7cfa3a

5 files changed

Lines changed: 241 additions & 10 deletions

File tree

‎docs/adr/0009-the-body-cap-counts-decoded-bytes.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
status. `Content-Length` may reject early but never admit early, because it reports the compressed
55
size and a 1000:1 gzip bomb passes a header check by construction. It replaced an error-only
66
pre-check, since memory exhaustion has no status code, and it never applies to caller-driven
7-
`stream()` iteration, whose purpose is bodies too large to buffer. The response is rebuilt
8-
through `httpx2.Response(content=...)` with public API only: `elapsed`, `next_request` and
9-
`default_encoding` are copied over, and `num_bytes_downloaded`, which has no public setter, reads 0.
10-
Counting bytes in a wrapper around the response stream would avoid the rebuild; that is deferred to
11-
[#170](https://github.com/modern-python/httpware/issues/170).
7+
`stream()` iteration, whose purpose is bodies too large to buffer. For a body with no
8+
`Content-Encoding` the decoded bytes are the wire bytes, so the cap wraps the public
9+
`Response.stream` in a stream that counts bytes and raises once the count passes the cap, and httpx2
10+
reads the response with its own `read()`. The response stays the one httpx2 built. An encoded body
11+
is read through `iter_bytes()` and rebuilt with `httpx2.Response(content=...)`, using public API
12+
only: `elapsed`, `next_request` and `default_encoding` are copied over, and `num_bytes_downloaded`,
13+
which has no public setter, reads 0.

‎docs/errors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Unlike `DecodeError`, this error is raised before the request is sent.
202202

203203
Both clients accept `max_response_body_bytes: int | None = None`. By default there is no limit. When it is set, a response body larger than the cap raises `ResponseTooLargeError` instead of being returned, whatever the status: a `200` trips it as easily as a `500`. The cap counts decoded bytes, after decompression. It applies to `send()` and the verb methods, and to the error body that `stream()` reads before raising a `StatusError`. Bytes you read yourself while iterating a `stream()` are never capped, except with an `auth` that sets `requires_response_body`: that auth needs every body, so `stream()` reads the final one under the cap before yielding it. With a cap set, httpware runs the client's `auth` flow and, with `follow_redirects=True`, follows the redirects itself, and caps only the final response. It reads the body of each intermediate response, such as a redirect or a `DigestAuth` challenge, and throws it away, so the connection can be reused and the responses in `response.history` have no content. An intermediate body declared or read past the cap is not an error: httpware stops reading it and closes that connection. An `auth` that sets `requires_response_body`, like a token refresh that parses the token endpoint's JSON, gets each response read under the cap instead. Client `auth` is sent to the first URL only. `httpx2` keeps its `Authorization` header on a redirect within the same origin or from `http` to `https` on the same host, and drops it otherwise.
204204

205-
A response read under the cap is rebuilt from its decoded body. It keeps `elapsed`, `next_request` and the client's `default_encoding`. It loses the `Content-Encoding` and `Transfer-Encoding` headers, its `Content-Length` is the decoded size, and `num_bytes_downloaded` is `0`.
205+
A response read under the cap keeps `elapsed`, `next_request` and the client's `default_encoding`. A response without `Content-Encoding` is the one `httpx2` built, with the same headers and `num_bytes_downloaded` it has without a cap. A response with `Content-Encoding` is rebuilt from its decoded body: it loses the `Content-Encoding` and `Transfer-Encoding` headers, its `Content-Length` is the decoded size, and `num_bytes_downloaded` is `0`.
206206

207207
`ResponseTooLargeError` carries:
208208

‎src/httpware/_internal/body_cap.py‎

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Response-body cap enforcement: validate, read-capped (sync + async)."""
22

33
import contextlib
4+
import datetime as dt
45
import typing
5-
from collections.abc import Mapping
6+
from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Mapping
67
from http import HTTPStatus
78

89
import httpx2
@@ -98,6 +99,73 @@ def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int)
9899
return content_length
99100

100101

102+
class _CappedSyncStream(httpx2.SyncByteStream):
103+
"""Wrap a response stream, raising ResponseTooLargeError(reason="streamed") once it yields more than `cap` bytes."""
104+
105+
def __init__(self, stream: httpx2.SyncByteStream, cap: int, status_code: int, content_length: int | None) -> None:
106+
self._stream = stream
107+
self._cap = cap
108+
self._status_code = status_code
109+
self._content_length = content_length
110+
111+
@property
112+
def elapsed(self) -> dt.timedelta | None:
113+
"""The wrapped stream's `elapsed`, which httpx2 reads back as `Response.elapsed`."""
114+
return getattr(self._stream, "elapsed", None)
115+
116+
def __iter__(self) -> Iterator[bytes]:
117+
read = 0
118+
for chunk in self._stream:
119+
read += len(chunk)
120+
if read > self._cap:
121+
raise ResponseTooLargeError(
122+
status_code=self._status_code,
123+
limit=self._cap,
124+
content_length=self._content_length,
125+
reason="streamed",
126+
)
127+
yield chunk
128+
129+
def close(self) -> None:
130+
self._stream.close()
131+
132+
133+
class _CappedAsyncStream(httpx2.AsyncByteStream):
134+
"""Async mirror of `_CappedSyncStream`."""
135+
136+
def __init__(self, stream: httpx2.AsyncByteStream, cap: int, status_code: int, content_length: int | None) -> None:
137+
self._stream = stream
138+
self._cap = cap
139+
self._status_code = status_code
140+
self._content_length = content_length
141+
142+
@property
143+
def elapsed(self) -> dt.timedelta | None:
144+
"""The wrapped stream's `elapsed`, which httpx2 reads back as `Response.elapsed`."""
145+
return getattr(self._stream, "elapsed", None)
146+
147+
async def __aiter__(self) -> AsyncIterator[bytes]:
148+
chunks = self._stream.__aiter__()
149+
try:
150+
read = 0
151+
async for chunk in chunks:
152+
read += len(chunk)
153+
if read > self._cap:
154+
raise ResponseTooLargeError(
155+
status_code=self._status_code,
156+
limit=self._cap,
157+
content_length=self._content_length,
158+
reason="streamed",
159+
)
160+
yield chunk
161+
finally:
162+
if isinstance(chunks, AsyncGenerator):
163+
await chunks.aclose()
164+
165+
async def aclose(self) -> None:
166+
await self._stream.aclose()
167+
168+
101169
def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response:
102170
"""Rebuild a buffered Response from the closed streaming `response` and its final decoded `content`."""
103171
buffered = httpx2.Response(
@@ -120,13 +188,24 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
120188
121189
Raises `ResponseTooLargeError` (reason="declared") if the declared
122190
Content-Length already exceeds `cap` — before any byte is read — and
123-
(reason="streamed") if the decoded body crosses `cap` mid-read. Closes
124-
`response` once its whole body is read; on any other path the caller closes it.
191+
(reason="streamed") if the decoded body crosses `cap` mid-read. An unencoded
192+
body is read into `response` itself, which is closed on any error; an encoded
193+
one is read into a rebuilt Response. Closes `response` once its whole body is read;
194+
on any other path the caller closes it.
125195
"""
126196
if not _response_has_body(request.method, response.status_code):
127197
response.read() # empty body; preserve the original response (and its headers)
128198
return response
129199
content_length = _reject_if_declared_length_exceeds_cap(response, cap)
200+
if "content-encoding" not in response.headers:
201+
stream = typing.cast("httpx2.SyncByteStream", response.stream)
202+
response.stream = _CappedSyncStream(stream, cap, response.status_code, content_length)
203+
try:
204+
response.read()
205+
except BaseException:
206+
response.close()
207+
raise
208+
return response
130209
try:
131210
content = _accumulate_capped(response.iter_bytes(), cap)
132211
except _CapExceeded:
@@ -143,6 +222,15 @@ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx
143222
await response.aread() # empty body; preserve the original response (and its headers)
144223
return response
145224
content_length = _reject_if_declared_length_exceeds_cap(response, cap)
225+
if "content-encoding" not in response.headers:
226+
stream = typing.cast("httpx2.AsyncByteStream", response.stream)
227+
response.stream = _CappedAsyncStream(stream, cap, response.status_code, content_length)
228+
try:
229+
await response.aread()
230+
except BaseException:
231+
await response.aclose()
232+
raise
233+
return response
146234
buf = bytearray()
147235
async for chunk in response.aiter_bytes():
148236
buf += chunk

‎tests/test_body_cap.py‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
import gzip
10-
from collections.abc import AsyncIterator
10+
from collections.abc import AsyncIterator, Iterator
1111

1212
import httpx2
1313
import pytest
@@ -157,6 +157,69 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
157157
client.close()
158158

159159

160+
def test_read_capped_returns_the_same_response_for_an_unencoded_body() -> None:
161+
body = b"hello world"
162+
163+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
164+
return httpx2.Response(200, content=iter([body]))
165+
166+
client, resp = _sync_stream(handler)
167+
try:
168+
out = _read_capped(resp, 1000, resp.request)
169+
assert out is resp
170+
assert out.content == body
171+
assert out.is_closed
172+
assert out.num_bytes_downloaded == len(body)
173+
assert out.headers["transfer-encoding"] == "chunked"
174+
finally:
175+
resp.close()
176+
client.close()
177+
178+
179+
def test_read_capped_rebuilds_the_response_for_an_encoded_body() -> None:
180+
raw = gzip.compress(b"A" * 500)
181+
182+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
183+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
184+
185+
client, resp = _sync_stream(handler)
186+
try:
187+
out = _read_capped(resp, 1_000_000, resp.request)
188+
assert out is not resp
189+
assert out.num_bytes_downloaded == 0
190+
finally:
191+
resp.close()
192+
client.close()
193+
194+
195+
def test_read_capped_unencoded_over_cap_closes_the_stream() -> None:
196+
closed: list[bool] = []
197+
198+
class _Body(httpx2.SyncByteStream):
199+
def __iter__(self) -> Iterator[bytes]:
200+
yield b"a" * 50
201+
yield b"b" * 50
202+
203+
def close(self) -> None:
204+
closed.append(True)
205+
206+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
207+
return httpx2.Response(200, stream=_Body())
208+
209+
client, resp = _sync_stream(handler)
210+
try:
211+
with pytest.raises(ResponseTooLargeError) as caught:
212+
_read_capped(resp, 70, resp.request)
213+
assert caught.value.reason == "streamed"
214+
assert caught.value.limit == 70 # noqa: PLR2004 — cap above
215+
assert caught.value.status_code == 200 # noqa: PLR2004 — mirrors handler
216+
assert resp.is_closed
217+
assert closed == [True]
218+
finally:
219+
resp.close()
220+
client.close()
221+
222+
160223
# ---- async ----
161224

162225

@@ -259,6 +322,70 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
259322
await client.aclose()
260323

261324

325+
async def test_read_capped_async_returns_the_same_response_for_an_unencoded_body() -> None:
326+
body = b"hello world"
327+
328+
async def chunks() -> AsyncIterator[bytes]:
329+
yield body
330+
331+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
332+
return httpx2.Response(200, content=chunks())
333+
334+
client, resp = await _async_stream(handler)
335+
try:
336+
out = await _read_capped_async(resp, 1000, resp.request)
337+
assert out is resp
338+
assert out.content == body
339+
assert out.is_closed
340+
assert out.num_bytes_downloaded == len(body)
341+
finally:
342+
await resp.aclose()
343+
await client.aclose()
344+
345+
346+
async def test_read_capped_async_rebuilds_the_response_for_an_encoded_body() -> None:
347+
raw = gzip.compress(b"A" * 500)
348+
349+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
350+
return httpx2.Response(200, headers={"content-encoding": "gzip"}, content=raw)
351+
352+
client, resp = await _async_stream(handler)
353+
try:
354+
out = await _read_capped_async(resp, 1_000_000, resp.request)
355+
assert out is not resp
356+
assert out.num_bytes_downloaded == 0
357+
finally:
358+
await resp.aclose()
359+
await client.aclose()
360+
361+
362+
async def test_read_capped_async_unencoded_over_cap_closes_the_stream() -> None:
363+
closed: list[bool] = []
364+
365+
class _Body(httpx2.AsyncByteStream):
366+
async def __aiter__(self) -> AsyncIterator[bytes]:
367+
yield b"a" * 50
368+
yield b"b" * 50
369+
370+
async def aclose(self) -> None:
371+
closed.append(True)
372+
373+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
374+
return httpx2.Response(200, stream=_Body())
375+
376+
client, resp = await _async_stream(handler)
377+
try:
378+
with pytest.raises(ResponseTooLargeError) as caught:
379+
await _read_capped_async(resp, 70, resp.request)
380+
assert caught.value.reason == "streamed"
381+
assert caught.value.limit == 70 # noqa: PLR2004 — cap above
382+
assert resp.is_closed
383+
assert closed == [True]
384+
finally:
385+
await resp.aclose()
386+
await client.aclose()
387+
388+
262389
# ---- content-length parsing ----
263390

264391

‎tests/test_client_body_cap.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,17 @@ async def test_async_response_keeps_default_encoding_with_or_without_a_cap(cap:
277277
) as client:
278278
response = await client.get("https://example.test/x")
279279
assert response.text == "café"
280+
281+
282+
@pytest.mark.parametrize("cap", [None, 1024])
283+
def test_sync_response_keeps_num_bytes_downloaded_with_or_without_a_cap(cap: int | None) -> None:
284+
with Client(transport=httpx2.MockTransport(_streamed_body), max_response_body_bytes=cap) as client:
285+
response = client.get("https://example.test/x")
286+
assert response.num_bytes_downloaded == len(b"hello")
287+
288+
289+
@pytest.mark.parametrize("cap", [None, 1024])
290+
async def test_async_response_keeps_num_bytes_downloaded_with_or_without_a_cap(cap: int | None) -> None:
291+
async with AsyncClient(transport=httpx2.MockTransport(_streamed_body_async), max_response_body_bytes=cap) as client:
292+
response = await client.get("https://example.test/x")
293+
assert response.num_bytes_downloaded == len(b"hello")

0 commit comments

Comments
 (0)