httpx/tests/client/test_redirects.py
Takashi Kajinami f0e232fc94 Add option to keep the same method for 301/302 redirects
RFC hasn't been clear enough about the expected behavior for 301 and
302 response. While it's (unfortunately) common for browsers to switch
the original http method to GET when following redirects, some server
applications expects the "legacy" behavior which keeps the same method
over redirects.

Add new option to clients to select the behavior.

Signed-off-by: Takashi Kajinami <kajinamit@oss.nttdata.com>
2026-02-26 23:32:14 +09:00

484 lines
18 KiB
Python

import typing
import pytest
import httpx
def redirects(request: httpx.Request) -> httpx.Response:
if request.url.scheme not in ("http", "https"):
raise httpx.UnsupportedProtocol(f"Scheme {request.url.scheme!r} not supported.")
if request.url.path == "/redirect_301":
status_code = httpx.codes.MOVED_PERMANENTLY
content = b"<a href='https://example.org/'>here</a>"
headers = {"location": "https://example.org/"}
return httpx.Response(status_code, headers=headers, content=content)
elif request.url.path == "/redirect_302":
status_code = httpx.codes.FOUND
headers = {"location": "https://example.org/"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/redirect_303":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "https://example.org/"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/relative_redirect":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "/"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/malformed_redirect":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "https://:443/"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/invalid_redirect":
status_code = httpx.codes.SEE_OTHER
raw_headers = [(b"location", "https://😇/".encode("utf-8"))]
return httpx.Response(status_code, headers=raw_headers)
elif request.url.path == "/no_scheme_redirect":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "//example.org/"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/multiple_redirects":
params = httpx.QueryParams(request.url.query)
count = int(params.get("count", "0"))
redirect_count = count - 1
status_code = httpx.codes.SEE_OTHER if count else httpx.codes.OK
if count:
location = "/multiple_redirects"
if redirect_count:
location += f"?count={redirect_count}"
headers = {"location": location}
else:
headers = {}
return httpx.Response(status_code, headers=headers)
if request.url.path == "/redirect_loop":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "/redirect_loop"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/cross_domain":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "https://example.org/cross_domain_target"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/cross_domain_target":
status_code = httpx.codes.OK
data = {
"body": request.content.decode("ascii"),
"headers": dict(request.headers),
}
return httpx.Response(status_code, json=data)
elif request.url.path == "/redirect_body":
status_code = httpx.codes.PERMANENT_REDIRECT
headers = {"location": "/redirect_body_target"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/redirect_no_body":
status_code = httpx.codes.SEE_OTHER
headers = {"location": "/redirect_body_target"}
return httpx.Response(status_code, headers=headers)
elif request.url.path == "/redirect_body_target":
data = {
"body": request.content.decode("ascii"),
"headers": dict(request.headers),
}
return httpx.Response(200, json=data)
elif request.url.path == "/cross_subdomain":
if request.headers["Host"] != "www.example.org":
status_code = httpx.codes.PERMANENT_REDIRECT
headers = {"location": "https://www.example.org/cross_subdomain"}
return httpx.Response(status_code, headers=headers)
else:
return httpx.Response(200, text="Hello, world!")
elif request.url.path == "/redirect_custom_scheme":
status_code = httpx.codes.MOVED_PERMANENTLY
headers = {"location": "market://details?id=42"}
return httpx.Response(status_code, headers=headers)
if request.method == "HEAD":
return httpx.Response(200)
return httpx.Response(200, html="<html><body>Hello, world!</body></html>")
def test_redirect_301():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.post("https://example.org/redirect_301", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "GET"
assert len(response.history) == 1
def test_redirect_301_keep_method():
client = httpx.Client(
transport=httpx.MockTransport(redirects), keep_method_for_redirects=True
)
response = client.post("https://example.org/redirect_301", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "POST"
assert len(response.history) == 1
def test_redirect_302():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.post("https://example.org/redirect_302", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "GET"
assert len(response.history) == 1
def test_redirect_302_keep_method():
client = httpx.Client(
transport=httpx.MockTransport(redirects), keep_method_for_redirects=True
)
response = client.post("https://example.org/redirect_302", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "POST"
assert len(response.history) == 1
def test_redirect_303():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get("https://example.org/redirect_303", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "GET"
assert len(response.history) == 1
def test_redirect_303_keep_method():
client = httpx.Client(
transport=httpx.MockTransport(redirects), keep_method_for_redirects=True
)
response = client.get("https://example.org/redirect_303", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "GET"
assert len(response.history) == 1
def test_next_request():
client = httpx.Client(transport=httpx.MockTransport(redirects))
request = client.build_request("POST", "https://example.org/redirect_303")
response = client.send(request, follow_redirects=False)
assert response.status_code == httpx.codes.SEE_OTHER
assert response.url == "https://example.org/redirect_303"
assert response.next_request is not None
response = client.send(response.next_request, follow_redirects=False)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.next_request is None
@pytest.mark.anyio
async def test_async_next_request():
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
request = client.build_request("POST", "https://example.org/redirect_303")
response = await client.send(request, follow_redirects=False)
assert response.status_code == httpx.codes.SEE_OTHER
assert response.url == "https://example.org/redirect_303"
assert response.next_request is not None
response = await client.send(response.next_request, follow_redirects=False)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.next_request is None
def test_head_redirect():
"""
Contrary to Requests, redirects remain enabled by default for HEAD requests.
"""
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.head("https://example.org/redirect_302", follow_redirects=True)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert response.request.method == "HEAD"
assert len(response.history) == 1
assert response.text == ""
def test_relative_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get(
"https://example.org/relative_redirect", follow_redirects=True
)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert len(response.history) == 1
def test_malformed_redirect():
# https://github.com/encode/httpx/issues/771
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get(
"http://example.org/malformed_redirect", follow_redirects=True
)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org:443/"
assert len(response.history) == 1
def test_invalid_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
with pytest.raises(httpx.RemoteProtocolError):
client.get("http://example.org/invalid_redirect", follow_redirects=True)
def test_no_scheme_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get(
"https://example.org/no_scheme_redirect", follow_redirects=True
)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/"
assert len(response.history) == 1
def test_fragment_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get(
"https://example.org/relative_redirect#fragment", follow_redirects=True
)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/#fragment"
assert len(response.history) == 1
def test_multiple_redirects():
client = httpx.Client(transport=httpx.MockTransport(redirects))
response = client.get(
"https://example.org/multiple_redirects?count=20", follow_redirects=True
)
assert response.status_code == httpx.codes.OK
assert response.url == "https://example.org/multiple_redirects"
assert len(response.history) == 20
assert response.history[0].url == "https://example.org/multiple_redirects?count=20"
assert response.history[1].url == "https://example.org/multiple_redirects?count=19"
assert len(response.history[0].history) == 0
assert len(response.history[1].history) == 1
@pytest.mark.anyio
async def test_async_too_many_redirects():
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
with pytest.raises(httpx.TooManyRedirects):
await client.get(
"https://example.org/multiple_redirects?count=21", follow_redirects=True
)
def test_sync_too_many_redirects():
client = httpx.Client(transport=httpx.MockTransport(redirects))
with pytest.raises(httpx.TooManyRedirects):
client.get(
"https://example.org/multiple_redirects?count=21", follow_redirects=True
)
def test_redirect_loop():
client = httpx.Client(transport=httpx.MockTransport(redirects))
with pytest.raises(httpx.TooManyRedirects):
client.get("https://example.org/redirect_loop", follow_redirects=True)
def test_cross_domain_redirect_with_auth_header():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.com/cross_domain"
headers = {"Authorization": "abc"}
response = client.get(url, headers=headers, follow_redirects=True)
assert response.url == "https://example.org/cross_domain_target"
assert "authorization" not in response.json()["headers"]
def test_cross_domain_https_redirect_with_auth_header():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "http://example.com/cross_domain"
headers = {"Authorization": "abc"}
response = client.get(url, headers=headers, follow_redirects=True)
assert response.url == "https://example.org/cross_domain_target"
assert "authorization" not in response.json()["headers"]
def test_cross_domain_redirect_with_auth():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.com/cross_domain"
response = client.get(url, auth=("user", "pass"), follow_redirects=True)
assert response.url == "https://example.org/cross_domain_target"
assert "authorization" not in response.json()["headers"]
def test_same_domain_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.org/cross_domain"
headers = {"Authorization": "abc"}
response = client.get(url, headers=headers, follow_redirects=True)
assert response.url == "https://example.org/cross_domain_target"
assert response.json()["headers"]["authorization"] == "abc"
def test_same_domain_https_redirect_with_auth_header():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "http://example.org/cross_domain"
headers = {"Authorization": "abc"}
response = client.get(url, headers=headers, follow_redirects=True)
assert response.url == "https://example.org/cross_domain_target"
assert response.json()["headers"]["authorization"] == "abc"
def test_body_redirect():
"""
A 308 redirect should preserve the request body.
"""
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.org/redirect_body"
content = b"Example request body"
response = client.post(url, content=content, follow_redirects=True)
assert response.url == "https://example.org/redirect_body_target"
assert response.json()["body"] == "Example request body"
assert "content-length" in response.json()["headers"]
def test_no_body_redirect():
"""
A 303 redirect should remove the request body.
"""
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.org/redirect_no_body"
content = b"Example request body"
response = client.post(url, content=content, follow_redirects=True)
assert response.url == "https://example.org/redirect_body_target"
assert response.json()["body"] == ""
assert "content-length" not in response.json()["headers"]
def test_can_stream_if_no_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.org/redirect_301"
with client.stream("GET", url, follow_redirects=False) as response:
pass
assert response.status_code == httpx.codes.MOVED_PERMANENTLY
assert response.headers["location"] == "https://example.org/"
class ConsumeBodyTransport(httpx.MockTransport):
def handle_request(self, request: httpx.Request) -> httpx.Response:
assert isinstance(request.stream, httpx.SyncByteStream)
list(request.stream)
return self.handler(request) # type: ignore[return-value]
def test_cannot_redirect_streaming_body():
client = httpx.Client(transport=ConsumeBodyTransport(redirects))
url = "https://example.org/redirect_body"
def streaming_body() -> typing.Iterator[bytes]:
yield b"Example request body" # pragma: no cover
with pytest.raises(httpx.StreamConsumed):
client.post(url, content=streaming_body(), follow_redirects=True)
def test_cross_subdomain_redirect():
client = httpx.Client(transport=httpx.MockTransport(redirects))
url = "https://example.com/cross_subdomain"
response = client.get(url, follow_redirects=True)
assert response.url == "https://www.example.org/cross_subdomain"
def cookie_sessions(request: httpx.Request) -> httpx.Response:
if request.url.path == "/":
cookie = request.headers.get("Cookie")
if cookie is not None:
content = b"Logged in"
else:
content = b"Not logged in"
return httpx.Response(200, content=content)
elif request.url.path == "/login":
status_code = httpx.codes.SEE_OTHER
headers = {
"location": "/",
"set-cookie": (
"session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
"httponly; samesite=lax"
),
}
return httpx.Response(status_code, headers=headers)
else:
assert request.url.path == "/logout"
status_code = httpx.codes.SEE_OTHER
headers = {
"location": "/",
"set-cookie": (
"session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
"httponly; samesite=lax"
),
}
return httpx.Response(status_code, headers=headers)
def test_redirect_cookie_behavior():
client = httpx.Client(
transport=httpx.MockTransport(cookie_sessions), follow_redirects=True
)
# The client is not logged in.
response = client.get("https://example.com/")
assert response.url == "https://example.com/"
assert response.text == "Not logged in"
# Login redirects to the homepage, setting a session cookie.
response = client.post("https://example.com/login")
assert response.url == "https://example.com/"
assert response.text == "Logged in"
# The client is logged in.
response = client.get("https://example.com/")
assert response.url == "https://example.com/"
assert response.text == "Logged in"
# Logout redirects to the homepage, expiring the session cookie.
response = client.post("https://example.com/logout")
assert response.url == "https://example.com/"
assert response.text == "Not logged in"
# The client is not logged in.
response = client.get("https://example.com/")
assert response.url == "https://example.com/"
assert response.text == "Not logged in"
def test_redirect_custom_scheme():
client = httpx.Client(transport=httpx.MockTransport(redirects))
with pytest.raises(httpx.UnsupportedProtocol) as e:
client.post("https://example.org/redirect_custom_scheme", follow_redirects=True)
assert str(e.value) == "Scheme 'market' not supported."
@pytest.mark.anyio
async def test_async_invalid_redirect():
async with httpx.AsyncClient(transport=httpx.MockTransport(redirects)) as client:
with pytest.raises(httpx.RemoteProtocolError):
await client.get(
"http://example.org/invalid_redirect", follow_redirects=True
)