diff --git a/docs/adr/0009-the-body-cap-counts-decoded-bytes.md b/docs/adr/0009-the-body-cap-counts-decoded-bytes.md index 5a2a864..86ec7fe 100644 --- a/docs/adr/0009-the-body-cap-counts-decoded-bytes.md +++ b/docs/adr/0009-the-body-cap-counts-decoded-bytes.md @@ -4,6 +4,8 @@ status. `Content-Length` may reject early but never admit early, because it reports the compressed size and a 1000:1 gzip bomb passes a header check by construction. It replaced an error-only pre-check, since memory exhaustion has no status code, and it never applies to caller-driven -`stream()` iteration, whose purpose is bodies too large to buffer. The rebuild through -`httpx2.Response(content=...)` uses only public API and loses `response.elapsed` when a cap is set. -That was accepted over reaching into httpx2 internals. +`stream()` iteration, whose purpose is bodies too large to buffer. The response is rebuilt +through `httpx2.Response(content=...)` with public API only: `elapsed`, `next_request` and +`default_encoding` are copied over, and `num_bytes_downloaded`, which has no public setter, reads 0. +Counting bytes in a wrapper around the response stream would avoid the rebuild; that is deferred to +[#170](https://github.com/modern-python/httpware/issues/170). diff --git a/docs/errors.md b/docs/errors.md index 8de0c3d..2c3e365 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -200,7 +200,9 @@ Unlike `DecodeError`, this error is raised before the request is sent. ## `ResponseTooLargeError` -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 closes each intermediate response, such as a redirect or a `DigestAuth` challenge, without reading its body, so the responses in `response.history` have no content. 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. +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. + +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`. `ResponseTooLargeError` carries: diff --git a/src/httpware/_internal/body_cap.py b/src/httpware/_internal/body_cap.py index 8bee3ae..41b4825 100644 --- a/src/httpware/_internal/body_cap.py +++ b/src/httpware/_internal/body_cap.py @@ -1,5 +1,6 @@ """Response-body cap enforcement: validate, read-capped (sync + async).""" +import contextlib import typing from collections.abc import Mapping from http import HTTPStatus @@ -98,15 +99,20 @@ def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int) def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response: - """Rebuild a buffered Response from the original streaming `response` and the final decoded `content`.""" - return httpx2.Response( + """Rebuild a buffered Response from the closed streaming `response` and its final decoded `content`.""" + buffered = httpx2.Response( status_code=response.status_code, headers=_buffered_headers(response.headers), content=content, request=request, extensions=_safe_extensions(response.extensions), history=response.history, + default_encoding=response.default_encoding, ) + buffered.next_request = response.next_request + with contextlib.suppress(RuntimeError): # httpx2 has no elapsed for a stream it never closed + buffered.elapsed = response.elapsed + return buffered def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response: @@ -114,8 +120,8 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) - Raises `ResponseTooLargeError` (reason="declared") if the declared Content-Length already exceeds `cap` — before any byte is read — and - (reason="streamed") if the decoded body crosses `cap` mid-read. Does not - close `response`; the caller owns the stream lifecycle. + (reason="streamed") if the decoded body crosses `cap` mid-read. Closes + `response` once its whole body is read; on any other path the caller closes it. """ if not _response_has_body(request.method, response.status_code): response.read() # empty body; preserve the original response (and its headers) @@ -127,6 +133,7 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) - raise ResponseTooLargeError( status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed" ) from None + response.close() return _build_buffered_response(response, content, request) @@ -143,4 +150,36 @@ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx raise ResponseTooLargeError( status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed" ) + await response.aclose() return _build_buffered_response(response, bytes(buf), request) + + +def _fits_declared_length(response: httpx2.Response, cap: int) -> bool: + """Whether `response` may carry a body within `cap`: no body, or no Content-Length above it.""" + if not _response_has_body(response.request.method, response.status_code): + return True + content_length = _parse_content_length(response.headers.get("content-length")) + return content_length is None or content_length <= cap + + +def _discard_within_cap(response: httpx2.Response, cap: int) -> None: + """Read and discard `response`'s body, stopping once it is declared or read past `cap` decoded bytes.""" + if not _fits_declared_length(response, cap): + return + read = 0 + for chunk in response.iter_bytes(): + read += len(chunk) + if read > cap: + return + + +async def _discard_within_cap_async(response: httpx2.Response, cap: int) -> None: + """Async mirror of `_discard_within_cap`.""" + if not _fits_declared_length(response, cap): + return + read = 0 + async with contextlib.aclosing(response.aiter_bytes()) as chunks: + async for chunk in chunks: + read += len(chunk) + if read > cap: + return diff --git a/src/httpware/client.py b/src/httpware/client.py index bedcde7..48619dc 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -11,7 +11,13 @@ import typing_extensions from httpware._internal import import_checker -from httpware._internal.body_cap import _read_capped, _read_capped_async, _validate_max_response_body_bytes +from httpware._internal.body_cap import ( + _discard_within_cap, + _discard_within_cap_async, + _read_capped, + _read_capped_async, + _validate_max_response_body_bytes, +) from httpware._internal.exception_mapping import ( _httpx2_exception_mapper, _httpx2_exception_mapper_sync, @@ -165,12 +171,22 @@ async def _read_capped_and_close_async(streaming: httpx2.Response, cap: int) -> await streaming.aclose() +async def _discard_and_close_async(response: httpx2.Response, cap: int) -> None: + """Close an intermediate `response`, first reading an unread body that fits `cap` so its connection is reused.""" + try: + if not response.is_stream_consumed: + await _discard_within_cap_async(response, cap) + finally: + await response.aclose() + + async def _send_redirect_hops_async( client: httpx2.AsyncClient, request: httpx2.Request, prior_history: list[httpx2.Response], + cap: int, ) -> httpx2.Response: - """Send `request` streaming, following redirects hop by hop without reading intermediate bodies. + """Send `request` streaming, following redirects hop by hop and keeping no intermediate body. Histories and the `max_redirects` count include `prior_history`, as in httpx2. """ @@ -182,13 +198,13 @@ async def _send_redirect_hops_async( response.history = list(hops) if not client.follow_redirects or response.next_request is None: return response - await response.aclose() + await _discard_and_close_async(response, cap) hops.append(response) request = response.next_request async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request, cap: int) -> httpx2.Response: - """Send `request` streaming, driving the client's auth flow and redirects without reading intermediate bodies. + """Send `request` streaming, driving the client's auth flow and redirects and keeping no intermediate body. An auth that sets `requires_response_body` gets each response buffered under `cap` instead. """ @@ -198,7 +214,7 @@ async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request try: request = await anext(flow) while True: - response = await _send_redirect_hops_async(client, request, history) + response = await _send_redirect_hops_async(client, request, history, cap) if auth.requires_response_body: response = await _read_capped_and_close_async(response, cap) try: @@ -208,7 +224,7 @@ async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request except BaseException: await response.aclose() raise - await response.aclose() + await _discard_and_close_async(response, cap) response.history = list(history) history.append(response) request = next_request @@ -252,10 +268,20 @@ def _read_capped_and_close(streaming: httpx2.Response, cap: int) -> httpx2.Respo streaming.close() +def _discard_and_close(response: httpx2.Response, cap: int) -> None: + """Sync mirror of `_discard_and_close_async`.""" + try: + if not response.is_stream_consumed: + _discard_within_cap(response, cap) + finally: + response.close() + + def _send_redirect_hops( client: httpx2.Client, request: httpx2.Request, prior_history: list[httpx2.Response], + cap: int, ) -> httpx2.Response: """Sync mirror of `_send_redirect_hops_async`.""" hops = list(prior_history) @@ -266,7 +292,7 @@ def _send_redirect_hops( response.history = list(hops) if not client.follow_redirects or response.next_request is None: return response - response.close() + _discard_and_close(response, cap) hops.append(response) request = response.next_request @@ -279,7 +305,7 @@ def _send_capped(client: httpx2.Client, request: httpx2.Request, cap: int) -> ht try: request = next(flow) while True: - response = _send_redirect_hops(client, request, history) + response = _send_redirect_hops(client, request, history, cap) if auth.requires_response_body: response = _read_capped_and_close(response, cap) try: @@ -289,7 +315,7 @@ def _send_capped(client: httpx2.Client, request: httpx2.Request, cap: int) -> ht except BaseException: response.close() raise - response.close() + _discard_and_close(response, cap) response.history = list(history) history.append(response) request = next_request diff --git a/tests/test_client_body_cap.py b/tests/test_client_body_cap.py index fe9d086..a10aeeb 100644 --- a/tests/test_client_body_cap.py +++ b/tests/test_client_body_cap.py @@ -5,8 +5,10 @@ coverage lives in tests/test_client_stream*.py. """ +import datetime as dt import gzip from collections.abc import AsyncIterator +from http import HTTPStatus import httpx2 import pytest @@ -204,3 +206,74 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 request = client.build_request("GET", "https://example.test/x") assert (await client.send(request)).content == body await client.aclose() + + +# ---- response attributes kept with or without a cap ---- + + +def _latin1_body(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + return httpx2.Response(HTTPStatus.OK, headers={"content-type": "text/plain"}, content="café".encode("latin-1")) + + +def _streamed_body(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + return httpx2.Response(HTTPStatus.OK, content=iter([b"hello"])) + + +async def _hello() -> AsyncIterator[bytes]: + yield b"hello" + + +def _streamed_body_async(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + return httpx2.Response(HTTPStatus.OK, content=_hello()) + + +def _redirect(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/next"}) + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_response_keeps_elapsed_with_or_without_a_cap(cap: int | None) -> None: + with Client(transport=httpx2.MockTransport(_streamed_body), max_response_body_bytes=cap) as client: + response = client.get("https://example.test/x") + assert response.elapsed >= dt.timedelta(0) + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_response_keeps_next_request_with_or_without_a_cap(cap: int | None) -> None: + with Client(transport=httpx2.MockTransport(_redirect), max_response_body_bytes=cap) as client: + response = client.get("https://example.test/x") + assert response.next_request is not None + assert str(response.next_request.url) == "https://example.test/next" + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_response_keeps_default_encoding_with_or_without_a_cap(cap: int | None) -> None: + with Client( + transport=httpx2.MockTransport(_latin1_body), default_encoding="latin-1", max_response_body_bytes=cap + ) as client: + response = client.get("https://example.test/x") + assert response.text == "café" + + +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_response_keeps_elapsed_with_or_without_a_cap(cap: int | None) -> None: + async with AsyncClient(transport=httpx2.MockTransport(_streamed_body_async), max_response_body_bytes=cap) as client: + response = await client.get("https://example.test/x") + assert response.elapsed >= dt.timedelta(0) + + +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_response_keeps_next_request_with_or_without_a_cap(cap: int | None) -> None: + async with AsyncClient(transport=httpx2.MockTransport(_redirect), max_response_body_bytes=cap) as client: + response = await client.get("https://example.test/x") + assert response.next_request is not None + assert str(response.next_request.url) == "https://example.test/next" + + +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_response_keeps_default_encoding_with_or_without_a_cap(cap: int | None) -> None: + async with AsyncClient( + transport=httpx2.MockTransport(_latin1_body), default_encoding="latin-1", max_response_body_bytes=cap + ) as client: + response = await client.get("https://example.test/x") + assert response.text == "café" diff --git a/tests/test_client_body_cap_auth.py b/tests/test_client_body_cap_auth.py index 151fb51..ff5457d 100644 --- a/tests/test_client_body_cap_auth.py +++ b/tests/test_client_body_cap_auth.py @@ -63,10 +63,10 @@ def huge_body() -> Iterator[bytes]: return _digest_challenge(huge_body) -@pytest.mark.parametrize(("cap", "challenge_read"), [(None, True), (1024, False)]) -async def test_async_reads_a_digest_challenge_body_only_without_a_cap( +@pytest.mark.parametrize(("cap", "chunks_pulled"), [(None, 100), (1024, 2)]) +async def test_async_reads_a_digest_challenge_body_only_up_to_the_cap( cap: int | None, - challenge_read: bool, + chunks_pulled: int, ) -> None: pulled: list[bytes] = [] async with AsyncClient( @@ -76,13 +76,31 @@ async def test_async_reads_a_digest_challenge_body_only_without_a_cap( ) as client: response = await client.get("https://example.test/") assert response.content == b"done" - assert bool(pulled) is challenge_read + assert len(pulled) == chunks_pulled -@pytest.mark.parametrize(("cap", "challenge_read"), [(None, True), (1024, False)]) -def test_sync_reads_a_digest_challenge_body_only_without_a_cap( +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_reads_a_digest_challenge_body_to_the_end_with_or_without_a_cap(cap: int | None) -> None: + read_to_end: list[bool] = [] + + async def body() -> AsyncIterator[bytes]: + yield b"unauthorized" + read_to_end.append(True) + + async with AsyncClient( + transport=_digest_challenge(body), + auth=httpx2.DigestAuth("u", "p"), + max_response_body_bytes=cap, + ) as client: + response = await client.get("https://example.test/") + assert response.content == b"done" + assert read_to_end == [True] + + +@pytest.mark.parametrize(("cap", "chunks_pulled"), [(None, 100), (1024, 2)]) +def test_sync_reads_a_digest_challenge_body_only_up_to_the_cap( cap: int | None, - challenge_read: bool, + chunks_pulled: int, ) -> None: pulled: list[bytes] = [] with Client( @@ -92,7 +110,25 @@ def test_sync_reads_a_digest_challenge_body_only_without_a_cap( ) as client: response = client.get("https://example.test/") assert response.content == b"done" - assert bool(pulled) is challenge_read + assert len(pulled) == chunks_pulled + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_reads_a_digest_challenge_body_to_the_end_with_or_without_a_cap(cap: int | None) -> None: + read_to_end: list[bool] = [] + + def body() -> Iterator[bytes]: + yield b"unauthorized" + read_to_end.append(True) + + with Client( + transport=_digest_challenge(body), + auth=httpx2.DigestAuth("u", "p"), + max_response_body_bytes=cap, + ) as client: + response = client.get("https://example.test/") + assert response.content == b"done" + assert read_to_end == [True] async def test_async_auth_flow_reading_bodies_gets_them_under_the_cap() -> None: @@ -255,7 +291,7 @@ def test_sync_auth_steps_count_toward_max_redirects_with_or_without_a_cap(cap: i client.get("https://example.test/final") -async def test_async_stream_never_reads_a_digest_challenge_body_under_a_cap() -> None: +async def test_async_stream_reads_a_digest_challenge_body_only_up_to_the_cap() -> None: pulled: list[bytes] = [] async with ( AsyncClient( @@ -267,10 +303,10 @@ async def test_async_stream_never_reads_a_digest_challenge_body_under_a_cap() -> ): body = await response.aread() assert body == b"done" - assert pulled == [] + assert len(pulled) == 2 # noqa: PLR2004 (the chunk crossing the cap is the last one pulled) -def test_sync_stream_never_reads_a_digest_challenge_body_under_a_cap() -> None: +def test_sync_stream_reads_a_digest_challenge_body_only_up_to_the_cap() -> None: pulled: list[bytes] = [] with ( Client( @@ -282,7 +318,7 @@ def test_sync_stream_never_reads_a_digest_challenge_body_under_a_cap() -> None: ): body = response.read() assert body == b"done" - assert pulled == [] + assert len(pulled) == 2 # noqa: PLR2004 (the chunk crossing the cap is the last one pulled) async def test_async_stream_rejects_an_auth_read_body_over_the_cap() -> None: diff --git a/tests/test_client_body_cap_redirects.py b/tests/test_client_body_cap_redirects.py index c67c2e8..25e2ed2 100644 --- a/tests/test_client_body_cap_redirects.py +++ b/tests/test_client_body_cap_redirects.py @@ -48,10 +48,30 @@ def huge_body() -> Iterator[bytes]: return _redirect_with_body(huge_body) -def _redirect_with_body(body: Callable[[], AsyncIterator[bytes] | Iterator[bytes]]) -> httpx2.MockTransport: +def _small_intermediate_body(read_to_end: list[bool], headers: dict[str, str] | None = None) -> httpx2.MockTransport: + async def small_body() -> AsyncIterator[bytes]: + yield b"moved" + read_to_end.append(True) + + return _redirect_with_body(small_body, headers) + + +def _small_intermediate_body_sync( + read_to_end: list[bool], headers: dict[str, str] | None = None +) -> httpx2.MockTransport: + def small_body() -> Iterator[bytes]: + yield b"moved" + read_to_end.append(True) + + return _redirect_with_body(small_body, headers) + + +def _redirect_with_body( + body: Callable[[], AsyncIterator[bytes] | Iterator[bytes]], headers: dict[str, str] | None = None +) -> httpx2.MockTransport: def handler(request: httpx2.Request) -> httpx2.Response: if request.url.path == "/start": - return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/final"}, content=body()) + return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/final", **(headers or {})}, content=body()) return httpx2.Response(HTTPStatus.OK, content=b"done") return httpx2.MockTransport(handler) @@ -69,10 +89,10 @@ async def test_async_follows_redirects_under_a_body_cap() -> None: assert str(response.url) == "https://example.test/final" -@pytest.mark.parametrize(("cap", "intermediate_read"), [(None, True), (1024, False)]) -async def test_async_reads_an_intermediate_redirect_body_only_without_a_cap( +@pytest.mark.parametrize(("cap", "chunks_pulled"), [(None, 100), (1024, 2)]) +async def test_async_reads_an_intermediate_redirect_body_only_up_to_the_cap( cap: int | None, - intermediate_read: bool, + chunks_pulled: int, ) -> None: pulled: list[bytes] = [] async with AsyncClient( @@ -82,10 +102,10 @@ async def test_async_reads_an_intermediate_redirect_body_only_without_a_cap( ) as client: response = await client.get("https://example.test/start") assert response.content == b"done" - assert bool(pulled) is intermediate_read + assert len(pulled) == chunks_pulled -async def test_async_stream_never_reads_an_intermediate_redirect_body() -> None: +async def test_async_stream_reads_an_intermediate_redirect_body_only_up_to_the_cap() -> None: pulled: list[bytes] = [] async with ( AsyncClient( @@ -97,7 +117,36 @@ async def test_async_stream_never_reads_an_intermediate_redirect_body() -> None: ): body = await response.aread() assert body == b"done" - assert pulled == [] + assert len(pulled) == 2 # noqa: PLR2004 (the chunk crossing the cap is the last one pulled) + + +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_reads_an_intermediate_redirect_body_to_the_end_with_or_without_a_cap(cap: int | None) -> None: + read_to_end: list[bool] = [] + async with AsyncClient( + transport=_small_intermediate_body(read_to_end), + follow_redirects=True, + max_response_body_bytes=cap, + ) as client: + response = await client.get("https://example.test/start") + assert response.content == b"done" + assert read_to_end == [True] + + +@pytest.mark.parametrize(("method", "read_to_end_expected"), [("GET", []), ("HEAD", [True])]) +async def test_async_skips_an_intermediate_redirect_body_declared_over_the_cap_unless_bodiless( + method: str, + read_to_end_expected: list[bool], +) -> None: + read_to_end: list[bool] = [] + async with AsyncClient( + transport=_small_intermediate_body(read_to_end, headers={"content-length": "2048"}), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = await client.request(method, "https://example.test/start") + assert response.status_code == HTTPStatus.OK + assert read_to_end == read_to_end_expected async def test_async_records_redirect_history_under_a_body_cap() -> None: @@ -176,10 +225,10 @@ async def test_async_caller_provided_client_follows_redirects_under_a_body_cap() assert response.content == b"done" -@pytest.mark.parametrize(("cap", "intermediate_read"), [(None, True), (1024, False)]) -def test_sync_reads_an_intermediate_redirect_body_only_without_a_cap( +@pytest.mark.parametrize(("cap", "chunks_pulled"), [(None, 100), (1024, 2)]) +def test_sync_reads_an_intermediate_redirect_body_only_up_to_the_cap( cap: int | None, - intermediate_read: bool, + chunks_pulled: int, ) -> None: pulled: list[bytes] = [] with Client( @@ -189,10 +238,10 @@ def test_sync_reads_an_intermediate_redirect_body_only_without_a_cap( ) as client: response = client.get("https://example.test/start") assert response.content == b"done" - assert bool(pulled) is intermediate_read + assert len(pulled) == chunks_pulled -def test_sync_stream_never_reads_an_intermediate_redirect_body() -> None: +def test_sync_stream_reads_an_intermediate_redirect_body_only_up_to_the_cap() -> None: pulled: list[bytes] = [] with ( Client( @@ -204,7 +253,36 @@ def test_sync_stream_never_reads_an_intermediate_redirect_body() -> None: ): body = response.read() assert body == b"done" - assert pulled == [] + assert len(pulled) == 2 # noqa: PLR2004 (the chunk crossing the cap is the last one pulled) + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_reads_an_intermediate_redirect_body_to_the_end_with_or_without_a_cap(cap: int | None) -> None: + read_to_end: list[bool] = [] + with Client( + transport=_small_intermediate_body_sync(read_to_end), + follow_redirects=True, + max_response_body_bytes=cap, + ) as client: + response = client.get("https://example.test/start") + assert response.content == b"done" + assert read_to_end == [True] + + +@pytest.mark.parametrize(("method", "read_to_end_expected"), [("GET", []), ("HEAD", [True])]) +def test_sync_skips_an_intermediate_redirect_body_declared_over_the_cap_unless_bodiless( + method: str, + read_to_end_expected: list[bool], +) -> None: + read_to_end: list[bool] = [] + with Client( + transport=_small_intermediate_body_sync(read_to_end, headers={"content-length": "2048"}), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = client.request(method, "https://example.test/start") + assert response.status_code == HTTPStatus.OK + assert read_to_end == read_to_end_expected def test_sync_follows_redirects_under_a_body_cap() -> None: