GeezerVison is showing github.com — perf: count capped bytes on the stream for unencoded bodies (#192) · modern-python/httpware@a7cfa3a
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: docs/adr/0009-the-body-cap-counts-decoded-bytes.md +7-5Lines changed: 7 additions & 5 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| 4 | 4 | status. `Content-Length` may reject early but never admit early, because it reports the compressed | 5 | 5 | size and a 1000:1 gzip bomb passes a header check by construction. It replaced an error-only | 6 | 6 | 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. |
Collapse file Copy file name to clipboardExpand all lines: docs/errors.md +1-1Lines changed: 1 addition & 1 deletion - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -202,7 +202,7 @@ Unlike `DecodeError`, this error is raised before the request is sent. | 202 | 202 |
| 203 | 203 | 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. | 204 | 204 |
| 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`. | 206 | 206 |
| 207 | 207 | `ResponseTooLargeError` carries: | 208 | 208 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/httpware/_internal/body_cap.py +91-3Lines changed: 91 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | """Response-body cap enforcement: validate, read-capped (sync + async).""" | 2 | 2 |
| 3 | 3 | import contextlib | | 4 | +import datetime as dt | 4 | 5 | import typing | 5 | | -from collections.abc import Mapping | | 6 | +from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Mapping | 6 | 7 | from http import HTTPStatus | 7 | 8 |
| 8 | 9 | import httpx2 | @@ -98,6 +99,73 @@ def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int) | 98 | 99 | return content_length | 99 | 100 |
| 100 | 101 |
| | 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 | + | 101 | 169 | def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response: | 102 | 170 | """Rebuild a buffered Response from the closed streaming `response` and its final decoded `content`.""" | 103 | 171 | buffered = httpx2.Response( | @@ -120,13 +188,24 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) - | 120 | 188 |
| 121 | 189 | Raises `ResponseTooLargeError` (reason="declared") if the declared | 122 | 190 | 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. | 125 | 195 | """ | 126 | 196 | if not _response_has_body(request.method, response.status_code): | 127 | 197 | response.read() # empty body; preserve the original response (and its headers) | 128 | 198 | return response | 129 | 199 | 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 | 130 | 209 | try: | 131 | 210 | content = _accumulate_capped(response.iter_bytes(), cap) | 132 | 211 | except _CapExceeded: | @@ -143,6 +222,15 @@ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx | 143 | 222 | await response.aread() # empty body; preserve the original response (and its headers) | 144 | 223 | return response | 145 | 224 | 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 | 146 | 234 | buf = bytearray() | 147 | 235 | async for chunk in response.aiter_bytes(): | 148 | 236 | buf += chunk | |
Collapse file Copy file name to clipboardExpand all lines: tests/test_body_cap.py +128-1Lines changed: 128 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 7 | 7 | """ | 8 | 8 |
| 9 | 9 | import gzip | 10 | | -from collections.abc import AsyncIterator | | 10 | +from collections.abc import AsyncIterator, Iterator | 11 | 11 |
| 12 | 12 | import httpx2 | 13 | 13 | import pytest | @@ -157,6 +157,69 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 | 157 | 157 | client.close() | 158 | 158 |
| 159 | 159 |
| | 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 | + | 160 | 223 | # ---- async ---- | 161 | 224 |
| 162 | 225 |
| @@ -259,6 +322,70 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 | 259 | 322 | await client.aclose() | 260 | 323 |
| 261 | 324 |
| | 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 | + | 262 | 389 | # ---- content-length parsing ---- | 263 | 390 |
| 264 | 391 |
| |
Collapse file Copy file name to clipboardExpand all lines: tests/test_client_body_cap.py +14Lines changed: 14 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -277,3 +277,17 @@ async def test_async_response_keeps_default_encoding_with_or_without_a_cap(cap: | 277 | 277 | ) as client: | 278 | 278 | response = await client.get("https://example.test/x") | 279 | 279 | 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") |
|