Skip to content

Commit 4e79b3d

Browse files
committed
test: assert auth on each redirect hop; docs: state the exact Authorization rule
1 parent 9af1520 commit 4e79b3d

2 files changed

Lines changed: 35 additions & 23 deletions

File tree

‎docs/errors.md‎

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

187187
## `ResponseTooLargeError`
188188

189-
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.
189+
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.
190190

191191
`ResponseTooLargeError` carries:
192192

‎tests/test_client_body_cap_redirects.py‎

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ def _looping(request: httpx2.Request) -> httpx2.Response:
1414
return httpx2.Response(HTTPStatus.FOUND, headers={"location": request.url.path + "x"})
1515

1616

17-
def _authorization_seen(seen: dict[str, str | None], location: str) -> httpx2.MockTransport:
17+
def _authorization_seen(seen: dict[tuple[str, str], str | None], location: str) -> httpx2.MockTransport:
1818
def handler(request: httpx2.Request) -> httpx2.Response:
19-
seen[request.url.host] = request.headers.get("authorization")
19+
seen[request.url.host, request.url.path] = request.headers.get("authorization")
2020
if request.url.path == "/start":
2121
return httpx2.Response(HTTPStatus.FOUND, headers={"location": location})
2222
return httpx2.Response(HTTPStatus.OK)
@@ -30,18 +30,6 @@ def _redirecting(request: httpx2.Request) -> httpx2.Response:
3030
return httpx2.Response(HTTPStatus.OK, content=b"done")
3131

3232

33-
async def test_async_follows_redirects_under_a_body_cap() -> None:
34-
async with AsyncClient(
35-
transport=httpx2.MockTransport(_redirecting),
36-
follow_redirects=True,
37-
max_response_body_bytes=1024,
38-
) as client:
39-
response = await client.get("https://example.test/start")
40-
assert response.status_code == HTTPStatus.OK
41-
assert response.content == b"done"
42-
assert str(response.url) == "https://example.test/final"
43-
44-
4533
def _huge_intermediate_body(pulled: list[bytes]) -> httpx2.MockTransport:
4634
async def huge_body() -> AsyncIterator[bytes]:
4735
for _ in range(100):
@@ -69,6 +57,18 @@ def handler(request: httpx2.Request) -> httpx2.Response:
6957
return httpx2.MockTransport(handler)
7058

7159

60+
async def test_async_follows_redirects_under_a_body_cap() -> None:
61+
async with AsyncClient(
62+
transport=httpx2.MockTransport(_redirecting),
63+
follow_redirects=True,
64+
max_response_body_bytes=1024,
65+
) as client:
66+
response = await client.get("https://example.test/start")
67+
assert response.status_code == HTTPStatus.OK
68+
assert response.content == b"done"
69+
assert str(response.url) == "https://example.test/final"
70+
71+
7272
async def test_async_never_reads_an_intermediate_redirect_body() -> None:
7373
pulled: list[bytes] = []
7474
async with AsyncClient(
@@ -139,15 +139,21 @@ async def test_async_too_many_redirects_is_the_same_error_with_or_without_a_cap(
139139
@pytest.mark.parametrize(
140140
("location", "expected"),
141141
[
142-
("/final", {"example.test": "Basic dTpw"}),
143-
("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}),
142+
(
143+
"/final",
144+
{("example.test", "/start"): "Basic dTpw", ("example.test", "/final"): "Basic dTpw"},
145+
),
146+
(
147+
"https://other.test/final",
148+
{("example.test", "/start"): "Basic dTpw", ("other.test", "/final"): None},
149+
),
144150
],
145151
)
146152
async def test_async_client_auth_does_not_follow_a_redirect_to_another_origin(
147153
location: str,
148-
expected: dict[str, str | None],
154+
expected: dict[tuple[str, str], str | None],
149155
) -> None:
150-
seen: dict[str, str | None] = {}
156+
seen: dict[tuple[str, str], str | None] = {}
151157
async with AsyncClient(
152158
transport=_authorization_seen(seen, location),
153159
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
252258
@pytest.mark.parametrize(
253259
("location", "expected"),
254260
[
255-
("/final", {"example.test": "Basic dTpw"}),
256-
("https://other.test/final", {"example.test": "Basic dTpw", "other.test": None}),
261+
(
262+
"/final",
263+
{("example.test", "/start"): "Basic dTpw", ("example.test", "/final"): "Basic dTpw"},
264+
),
265+
(
266+
"https://other.test/final",
267+
{("example.test", "/start"): "Basic dTpw", ("other.test", "/final"): None},
268+
),
257269
],
258270
)
259271
def test_sync_client_auth_does_not_follow_a_redirect_to_another_origin(
260272
location: str,
261-
expected: dict[str, str | None],
273+
expected: dict[tuple[str, str], str | None],
262274
) -> None:
263-
seen: dict[str, str | None] = {}
275+
seen: dict[tuple[str, str], str | None] = {}
264276
with Client(
265277
transport=_authorization_seen(seen, location),
266278
auth=httpx2.BasicAuth("u", "p"),

0 commit comments

Comments
 (0)