From 9af152059404f73efa3e1ec3041b23b009c9dd3e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 3 Oct 2026 12:05:27 +0300 Subject: [PATCH 1/3] feat: follow redirects under the response body cap With max_response_body_bytes set and follow_redirects=True, httpware now follows redirects itself, closing intermediate responses unread and capping only the final one, instead of rejecting the combination. --- docs/errors.md | 2 +- src/httpware/client.py | 98 +++++++-- tests/test_client_body_cap_redirects.py | 279 ++++++++++++++++++++++++ tests/test_client_options.py | 21 -- 4 files changed, 358 insertions(+), 42 deletions(-) create mode 100644 tests/test_client_body_cap_redirects.py diff --git a/docs/errors.md b/docs/errors.md index 2d14230..2e331eb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -186,7 +186,7 @@ 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. Setting the cap together with `follow_redirects=True`, on the client or on a passed `httpx2_client`, raises `ValueError`, because `httpx2` reads every intermediate redirect body without the cap. +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. With a cap set and `follow_redirects=True`, httpware follows the redirects itself and caps only the final response. It closes each intermediate redirect response without reading its body, so the responses in `response.history` have no content. Client `auth` is sent to the first URL and on redirects within the same origin, never to another origin. `ResponseTooLargeError` carries: diff --git a/src/httpware/client.py b/src/httpware/client.py index d7bc077..57f90b9 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -39,10 +39,7 @@ "cert": "cert=... is deprecated by httpx2; pass verify= configured with .load_cert_chain().", "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", } -_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = ( - "follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every " - "intermediate redirect body without the cap." -) +_TOO_MANY_REDIRECTS_MESSAGE = "Exceeded maximum allowed redirects." _BASE_URL_QUERY_MESSAGE = ( "base_url must not contain a query string: httpx2 appends request paths after it, " "producing malformed URLs. Pass the query as params=... instead." @@ -118,12 +115,10 @@ def _select_httpx2_options( supported: frozenset[str], *, httpx2_client: httpx2.Client | httpx2.AsyncClient | None, - max_response_body_bytes: int | None, ) -> dict[str, typing.Any]: """Return the options to forward to the owned httpx2 client, dropping unset ones. - Raise TypeError for unsupported options or options combined with `httpx2_client`, and - ValueError when the client would follow redirects under a body cap. + Raise TypeError for unsupported options or options combined with `httpx2_client`. """ unsupported = sorted(options.keys() - supported) if unsupported: @@ -135,12 +130,67 @@ def _select_httpx2_options( forwarded = {name: value for name, value in options.items() if not _is_unset(value)} if httpx2_client is not None and forwarded: raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded))) - follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects") - if follows and max_response_body_bytes is not None: - raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE) return forwarded +async def _send_following_redirects_async(client: httpx2.AsyncClient, request: httpx2.Request) -> httpx2.Response: + """Send `request` streaming, following redirects hop by hop without reading intermediate bodies.""" + history: list[httpx2.Response] = [] + response = await client.send(request, stream=True, follow_redirects=False) + while client.follow_redirects and response.next_request is not None: + await response.aclose() + history.append(response) + if len(history) > client.max_redirects: + raise httpx2.TooManyRedirects(_TOO_MANY_REDIRECTS_MESSAGE, request=response.next_request) + response = await client.send(response.next_request, stream=True, follow_redirects=False, auth=None) + response.history = history + return response + + +@contextlib.asynccontextmanager +async def _stream_following_redirects_async( + client: httpx2.AsyncClient, + method: str, + url: httpx2.URL | str, + kwargs: dict[str, typing.Any], +) -> AsyncIterator[httpx2.Response]: + """Async mirror of `httpx2.AsyncClient.stream` that follows redirects via `_send_following_redirects_async`.""" + response = await _send_following_redirects_async(client, client.build_request(method, url, **kwargs)) + try: + yield response + finally: + await response.aclose() + + +def _send_following_redirects(client: httpx2.Client, request: httpx2.Request) -> httpx2.Response: + """Sync mirror of `_send_following_redirects_async`.""" + history: list[httpx2.Response] = [] + response = client.send(request, stream=True, follow_redirects=False) + while client.follow_redirects and response.next_request is not None: + response.close() + history.append(response) + if len(history) > client.max_redirects: + raise httpx2.TooManyRedirects(_TOO_MANY_REDIRECTS_MESSAGE, request=response.next_request) + response = client.send(response.next_request, stream=True, follow_redirects=False, auth=None) + response.history = history + return response + + +@contextlib.contextmanager +def _stream_following_redirects( + client: httpx2.Client, + method: str, + url: httpx2.URL | str, + kwargs: dict[str, typing.Any], +) -> Iterator[httpx2.Response]: + """Sync mirror of `_stream_following_redirects_async`.""" + response = _send_following_redirects(client, client.build_request(method, url, **kwargs)) + try: + yield response + finally: + response.close() + + def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures *, params: typing.Any | None, @@ -211,7 +261,6 @@ def __init__( httpx2_options, _AsyncClientOptions.__optional_keys__, httpx2_client=httpx2_client, - max_response_body_bytes=max_response_body_bytes, ) if httpx2_client is not None: _reject_base_url_query(httpx2_client.base_url) @@ -235,9 +284,9 @@ async def _terminal(self, request: httpx2.Request) -> httpx2.Response: if cap is None: response = await self._httpx2_client.send(request) else: - streaming = await self._httpx2_client.send(request, stream=True) + streaming = await _send_following_redirects_async(self._httpx2_client, request) try: - response = await _read_capped_async(streaming, cap, request) + response = await _read_capped_async(streaming, cap, streaming.request) finally: await streaming.aclose() except RuntimeError as exc: @@ -1087,9 +1136,14 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa files=files, ) - async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, merged_url, **kwargs) as response: + cap = self._max_response_body_bytes + opened = ( + self._httpx2_client.stream(method, merged_url, **kwargs) + if cap is None + else _stream_following_redirects_async(self._httpx2_client, method, merged_url, kwargs) + ) + async with _httpx2_exception_mapper(), opened as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - cap = self._max_response_body_bytes if cap is None: await response.aread() # pre-read body so exc.response.content works _raise_on_status_error(response) @@ -1148,7 +1202,6 @@ def __init__( httpx2_options, _ClientOptions.__optional_keys__, httpx2_client=httpx2_client, - max_response_body_bytes=max_response_body_bytes, ) if httpx2_client is not None: _reject_base_url_query(httpx2_client.base_url) @@ -1172,9 +1225,9 @@ def _terminal(self, request: httpx2.Request) -> httpx2.Response: if cap is None: response = self._httpx2_client.send(request) else: - streaming = self._httpx2_client.send(request, stream=True) + streaming = _send_following_redirects(self._httpx2_client, request) try: - response = _read_capped(streaming, cap, request) + response = _read_capped(streaming, cap, streaming.request) finally: streaming.close() except RuntimeError as exc: @@ -2045,9 +2098,14 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo files=files, ) - with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, merged_url, **kwargs) as response: + cap = self._max_response_body_bytes + opened = ( + self._httpx2_client.stream(method, merged_url, **kwargs) + if cap is None + else _stream_following_redirects(self._httpx2_client, method, merged_url, kwargs) + ) + with _httpx2_exception_mapper_sync(), opened as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - cap = self._max_response_body_bytes if cap is None: response.read() # pre-read body so exc.response.content works _raise_on_status_error(response) diff --git a/tests/test_client_body_cap_redirects.py b/tests/test_client_body_cap_redirects.py new file mode 100644 index 0000000..93ff999 --- /dev/null +++ b/tests/test_client_body_cap_redirects.py @@ -0,0 +1,279 @@ +"""max_response_body_bytes with follow_redirects=True: httpware follows the hops itself, under the cap.""" + +from collections.abc import AsyncIterator, Callable, Iterator +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import AsyncClient, Client +from httpware.errors import ResponseTooLargeError, TransportError + + +def _looping(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.FOUND, headers={"location": request.url.path + "x"}) + + +def _authorization_seen(seen: dict[str, str | None], location: str) -> httpx2.MockTransport: + def handler(request: httpx2.Request) -> httpx2.Response: + seen[request.url.host] = request.headers.get("authorization") + if request.url.path == "/start": + return httpx2.Response(HTTPStatus.FOUND, headers={"location": location}) + return httpx2.Response(HTTPStatus.OK) + + return httpx2.MockTransport(handler) + + +def _redirecting(request: httpx2.Request) -> httpx2.Response: + if request.url.path == "/start": + return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/final"}) + return httpx2.Response(HTTPStatus.OK, content=b"done") + + +async def test_async_follows_redirects_under_a_body_cap() -> None: + async with AsyncClient( + transport=httpx2.MockTransport(_redirecting), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = await client.get("https://example.test/start") + assert response.status_code == HTTPStatus.OK + assert response.content == b"done" + assert str(response.url) == "https://example.test/final" + + +def _huge_intermediate_body(pulled: list[bytes]) -> httpx2.MockTransport: + async def huge_body() -> AsyncIterator[bytes]: + for _ in range(100): + pulled.append(b"x" * 1024) + yield pulled[-1] + + return _redirect_with_body(huge_body) + + +def _huge_intermediate_body_sync(pulled: list[bytes]) -> httpx2.MockTransport: + def huge_body() -> Iterator[bytes]: + for _ in range(100): + pulled.append(b"x" * 1024) + yield pulled[-1] + + return _redirect_with_body(huge_body) + + +def _redirect_with_body(body: Callable[[], AsyncIterator[bytes] | Iterator[bytes]]) -> 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.OK, content=b"done") + + return httpx2.MockTransport(handler) + + +async def test_async_never_reads_an_intermediate_redirect_body() -> None: + pulled: list[bytes] = [] + async with AsyncClient( + transport=_huge_intermediate_body(pulled), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = await client.get("https://example.test/start") + assert response.content == b"done" + assert pulled == [] + + +async def test_async_stream_never_reads_an_intermediate_redirect_body() -> None: + pulled: list[bytes] = [] + async with ( + AsyncClient( + transport=_huge_intermediate_body(pulled), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client, + client.stream("GET", "https://example.test/start") as response, + ): + body = await response.aread() + assert body == b"done" + assert pulled == [] + + +async def test_async_records_redirect_history_under_a_body_cap() -> None: + async with AsyncClient( + transport=httpx2.MockTransport(_redirecting), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = await client.get("https://example.test/start") + assert [(hop.status_code, str(hop.url)) for hop in response.history] == [ + (HTTPStatus.FOUND, "https://example.test/start") + ] + + +async def test_async_rejects_a_final_body_over_the_cap_after_redirects() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path == "/start": + return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/final"}) + return httpx2.Response(HTTPStatus.OK, content=b"x" * 2048) + + async with AsyncClient( + transport=httpx2.MockTransport(handler), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + with pytest.raises(ResponseTooLargeError): + await client.get("https://example.test/start") + + +@pytest.mark.parametrize("cap", [None, 1024]) +async def test_async_too_many_redirects_is_the_same_error_with_or_without_a_cap(cap: int | None) -> None: + async with AsyncClient( + transport=httpx2.MockTransport(_looping), + follow_redirects=True, + max_redirects=3, + max_response_body_bytes=cap, + ) as client: + with pytest.raises(TransportError, match="Exceeded maximum allowed redirects") as caught: + await client.get("https://example.test/a") + assert type(caught.value) is TransportError + + +@pytest.mark.parametrize( + ("location", "expected"), + [ + ("/final", {"example.test": "Basic dTpw"}), + ("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}), + ], +) +async def test_async_client_auth_does_not_follow_a_redirect_to_another_origin( + location: str, + expected: dict[str, str | None], +) -> None: + seen: dict[str, str | None] = {} + async with AsyncClient( + transport=_authorization_seen(seen, location), + auth=httpx2.BasicAuth("u", "p"), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + await client.get("https://example.test/start") + assert seen == expected + + +async def test_async_caller_provided_client_follows_redirects_under_a_body_cap() -> None: + caller = httpx2.AsyncClient(transport=httpx2.MockTransport(_redirecting), follow_redirects=True) + async with AsyncClient(httpx2_client=caller, max_response_body_bytes=1024) as client: + response = await client.get("https://example.test/start") + await caller.aclose() + assert response.content == b"done" + + +def test_sync_never_reads_an_intermediate_redirect_body() -> None: + pulled: list[bytes] = [] + with Client( + transport=_huge_intermediate_body_sync(pulled), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = client.get("https://example.test/start") + assert response.content == b"done" + assert pulled == [] + + +def test_sync_stream_never_reads_an_intermediate_redirect_body() -> None: + pulled: list[bytes] = [] + with ( + Client( + transport=_huge_intermediate_body_sync(pulled), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client, + client.stream("GET", "https://example.test/start") as response, + ): + body = response.read() + assert body == b"done" + assert pulled == [] + + +def test_sync_follows_redirects_under_a_body_cap() -> None: + with Client( + transport=httpx2.MockTransport(_redirecting), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = client.get("https://example.test/start") + assert response.status_code == HTTPStatus.OK + assert response.content == b"done" + assert str(response.url) == "https://example.test/final" + + +def test_sync_records_redirect_history_under_a_body_cap() -> None: + with Client( + transport=httpx2.MockTransport(_redirecting), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = client.get("https://example.test/start") + assert [(hop.status_code, str(hop.url)) for hop in response.history] == [ + (HTTPStatus.FOUND, "https://example.test/start") + ] + + +def test_sync_rejects_a_final_body_over_the_cap_after_redirects() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path == "/start": + return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/final"}) + return httpx2.Response(HTTPStatus.OK, content=b"x" * 2048) + + with ( + Client( + transport=httpx2.MockTransport(handler), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client, + pytest.raises(ResponseTooLargeError), + ): + client.get("https://example.test/start") + + +@pytest.mark.parametrize("cap", [None, 1024]) +def test_sync_too_many_redirects_is_the_same_error_with_or_without_a_cap(cap: int | None) -> None: + with ( + Client( + transport=httpx2.MockTransport(_looping), + follow_redirects=True, + max_redirects=3, + max_response_body_bytes=cap, + ) as client, + pytest.raises(TransportError, match="Exceeded maximum allowed redirects") as caught, + ): + client.get("https://example.test/a") + assert type(caught.value) is TransportError + + +@pytest.mark.parametrize( + ("location", "expected"), + [ + ("/final", {"example.test": "Basic dTpw"}), + ("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}), + ], +) +def test_sync_client_auth_does_not_follow_a_redirect_to_another_origin( + location: str, + expected: dict[str, str | None], +) -> None: + seen: dict[str, str | None] = {} + with Client( + transport=_authorization_seen(seen, location), + auth=httpx2.BasicAuth("u", "p"), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + client.get("https://example.test/start") + assert seen == expected + + +def test_sync_caller_provided_client_follows_redirects_under_a_body_cap() -> None: + caller = httpx2.Client(transport=httpx2.MockTransport(_redirecting), follow_redirects=True) + with Client(httpx2_client=caller, max_response_body_bytes=1024) as client: + response = client.get("https://example.test/start") + caller.close() + assert response.content == b"done" diff --git a/tests/test_client_options.py b/tests/test_client_options.py index 9507a1e..6f643a7 100644 --- a/tests/test_client_options.py +++ b/tests/test_client_options.py @@ -124,27 +124,6 @@ def test_type_checkers_reject_unsupported_sync_options() -> None: Client(cert="client.pem") # ty: ignore[unknown-argument] -@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) -def test_follow_redirects_with_body_cap_is_valueerror(client_cls: type) -> None: - with pytest.raises(ValueError, match="follow_redirects"): - client_cls(follow_redirects=True, max_response_body_bytes=1024) - - -@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) -def test_caller_owned_client_following_redirects_with_body_cap_is_valueerror( - client_cls: type, - httpx2_name: str, -) -> None: - caller = getattr(httpx2, httpx2_name)(follow_redirects=True) - with pytest.raises(ValueError, match="follow_redirects"): - client_cls(httpx2_client=caller, max_response_body_bytes=1024) - - -@pytest.mark.parametrize("client_cls", [AsyncClient, Client]) -def test_body_cap_without_following_redirects_is_accepted(client_cls: type) -> None: - client_cls(follow_redirects=False, max_response_body_bytes=1024) - - async def test_async_transport_option_keeps_the_httpx2_client_owned() -> None: async with AsyncClient(transport=httpx2.MockTransport(_ok)) as client: response = await client.get("https://example.test/") From 4e79b3d0ee98d6fbe617de422cfca4327cb35506 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 3 Oct 2026 12:07:39 +0300 Subject: [PATCH 2/3] test: assert auth on each redirect hop; docs: state the exact Authorization rule --- docs/errors.md | 2 +- tests/test_client_body_cap_redirects.py | 56 +++++++++++++++---------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 2e331eb..e17fdca 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -186,7 +186,7 @@ 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. With a cap set and `follow_redirects=True`, httpware follows the redirects itself and caps only the final response. It closes each intermediate redirect response without reading its body, so the responses in `response.history` have no content. Client `auth` is sent to the first URL and on redirects within the same origin, never to another origin. +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. With a cap set and `follow_redirects=True`, httpware follows the redirects itself and caps only the final response. It closes each intermediate redirect response without reading its body, so the responses in `response.history` have no content. 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. `ResponseTooLargeError` carries: diff --git a/tests/test_client_body_cap_redirects.py b/tests/test_client_body_cap_redirects.py index 93ff999..7613176 100644 --- a/tests/test_client_body_cap_redirects.py +++ b/tests/test_client_body_cap_redirects.py @@ -14,9 +14,9 @@ def _looping(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(HTTPStatus.FOUND, headers={"location": request.url.path + "x"}) -def _authorization_seen(seen: dict[str, str | None], location: str) -> httpx2.MockTransport: +def _authorization_seen(seen: dict[tuple[str, str], str | None], location: str) -> httpx2.MockTransport: def handler(request: httpx2.Request) -> httpx2.Response: - seen[request.url.host] = request.headers.get("authorization") + seen[request.url.host, request.url.path] = request.headers.get("authorization") if request.url.path == "/start": return httpx2.Response(HTTPStatus.FOUND, headers={"location": location}) return httpx2.Response(HTTPStatus.OK) @@ -30,18 +30,6 @@ def _redirecting(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(HTTPStatus.OK, content=b"done") -async def test_async_follows_redirects_under_a_body_cap() -> None: - async with AsyncClient( - transport=httpx2.MockTransport(_redirecting), - follow_redirects=True, - max_response_body_bytes=1024, - ) as client: - response = await client.get("https://example.test/start") - assert response.status_code == HTTPStatus.OK - assert response.content == b"done" - assert str(response.url) == "https://example.test/final" - - def _huge_intermediate_body(pulled: list[bytes]) -> httpx2.MockTransport: async def huge_body() -> AsyncIterator[bytes]: for _ in range(100): @@ -69,6 +57,18 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.MockTransport(handler) +async def test_async_follows_redirects_under_a_body_cap() -> None: + async with AsyncClient( + transport=httpx2.MockTransport(_redirecting), + follow_redirects=True, + max_response_body_bytes=1024, + ) as client: + response = await client.get("https://example.test/start") + assert response.status_code == HTTPStatus.OK + assert response.content == b"done" + assert str(response.url) == "https://example.test/final" + + async def test_async_never_reads_an_intermediate_redirect_body() -> None: pulled: list[bytes] = [] async with AsyncClient( @@ -139,15 +139,21 @@ async def test_async_too_many_redirects_is_the_same_error_with_or_without_a_cap( @pytest.mark.parametrize( ("location", "expected"), [ - ("/final", {"example.test": "Basic dTpw"}), - ("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}), + ( + "/final", + {("example.test", "/start"): "Basic dTpw", ("example.test", "/final"): "Basic dTpw"}, + ), + ( + "https://other.test/final", + {("example.test", "/start"): "Basic dTpw", ("other.test", "/final"): None}, + ), ], ) async def test_async_client_auth_does_not_follow_a_redirect_to_another_origin( location: str, - expected: dict[str, str | None], + expected: dict[tuple[str, str], str | None], ) -> None: - seen: dict[str, str | None] = {} + seen: dict[tuple[str, str], str | None] = {} async with AsyncClient( transport=_authorization_seen(seen, location), auth=httpx2.BasicAuth("u", "p"), @@ -252,15 +258,21 @@ def test_sync_too_many_redirects_is_the_same_error_with_or_without_a_cap(cap: in @pytest.mark.parametrize( ("location", "expected"), [ - ("/final", {"example.test": "Basic dTpw"}), - ("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}), + ( + "/final", + {("example.test", "/start"): "Basic dTpw", ("example.test", "/final"): "Basic dTpw"}, + ), + ( + "https://other.test/final", + {("example.test", "/start"): "Basic dTpw", ("other.test", "/final"): None}, + ), ], ) def test_sync_client_auth_does_not_follow_a_redirect_to_another_origin( location: str, - expected: dict[str, str | None], + expected: dict[tuple[str, str], str | None], ) -> None: - seen: dict[str, str | None] = {} + seen: dict[tuple[str, str], str | None] = {} with Client( transport=_authorization_seen(seen, location), auth=httpx2.BasicAuth("u", "p"), From 1e21e4b769f293f92c2683427f1653da9f64fd53 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 3 Oct 2026 12:15:48 +0300 Subject: [PATCH 3/3] test: contrast intermediate redirect body reads with and without a cap --- tests/test_client_body_cap_redirects.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_client_body_cap_redirects.py b/tests/test_client_body_cap_redirects.py index 7613176..c67c2e8 100644 --- a/tests/test_client_body_cap_redirects.py +++ b/tests/test_client_body_cap_redirects.py @@ -69,16 +69,20 @@ async def test_async_follows_redirects_under_a_body_cap() -> None: assert str(response.url) == "https://example.test/final" -async def test_async_never_reads_an_intermediate_redirect_body() -> None: +@pytest.mark.parametrize(("cap", "intermediate_read"), [(None, True), (1024, False)]) +async def test_async_reads_an_intermediate_redirect_body_only_without_a_cap( + cap: int | None, + intermediate_read: bool, +) -> None: pulled: list[bytes] = [] async with AsyncClient( transport=_huge_intermediate_body(pulled), follow_redirects=True, - max_response_body_bytes=1024, + max_response_body_bytes=cap, ) as client: response = await client.get("https://example.test/start") assert response.content == b"done" - assert pulled == [] + assert bool(pulled) is intermediate_read async def test_async_stream_never_reads_an_intermediate_redirect_body() -> None: @@ -172,16 +176,20 @@ async def test_async_caller_provided_client_follows_redirects_under_a_body_cap() assert response.content == b"done" -def test_sync_never_reads_an_intermediate_redirect_body() -> None: +@pytest.mark.parametrize(("cap", "intermediate_read"), [(None, True), (1024, False)]) +def test_sync_reads_an_intermediate_redirect_body_only_without_a_cap( + cap: int | None, + intermediate_read: bool, +) -> None: pulled: list[bytes] = [] with Client( transport=_huge_intermediate_body_sync(pulled), follow_redirects=True, - max_response_body_bytes=1024, + max_response_body_bytes=cap, ) as client: response = client.get("https://example.test/start") assert response.content == b"done" - assert pulled == [] + assert bool(pulled) is intermediate_read def test_sync_stream_never_reads_an_intermediate_redirect_body() -> None: