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"here" 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="Hello, world!") 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 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 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 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 )