Repository: encode/httpx

Issue:
Handle redirects with missing hostname in `Location:`  header.
While doing something like this:

```
import httpx

def run_sync(url):
    with httpx.Client(verify=False) as client:
        response = client.get(url)
        print(response)

run_sync('http://62.28.16.253')
```

I get a `httpx.exceptions.InvalidURL: No host included in URL.`

However `curl -vLk http://62.28.16.253` works like normal, as well as other HTTP clients. I think this might be another sketchy server misconfiguration, but should work nonetheless.


Discussion:
Thanks so much for the report!

> I think this might be another sketchy server misconfiguration, but should work nonetheless.

Indeed. Appears to be a malformed 'Location' header in the response...

```shell
Pragma: no-cache
Location: https://:443/
Content-Type: text/html
```

We ought to handle that case gracefully, tho.

Resolving patch:
diff --git a/httpx/client.py b/httpx/client.py
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -330,6 +330,11 @@ def redirect_url(self, request: Request, response: Response) -> URL:
 
         url = URL(location, allow_relative=True)
 
+        # Handle malformed 'Location' headers that are "absolute" form, have no host.
+        # See: https://github.com/encode/httpx/issues/771
+        if url.scheme and not url.host:
+            url = url.copy_with(host=request.url.host)
+
         # Facilitate relative 'Location' headers, as allowed by RFC 7231.
         # (e.g. '/path/to/resource' instead of 'http://domain.tld/path/to/resource')
         if url.is_relative_url:
diff --git a/httpx/models.py b/httpx/models.py
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -201,7 +201,7 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
             or "port" in kwargs
         ):
             host = kwargs.pop("host", self.host)
-            port = kwargs.pop("port", self.port)
+            port = kwargs.pop("port", None if self.is_relative_url else self.port)
             username = kwargs.pop("username", self.username)
             password = kwargs.pop("password", self.password)
 
@@ -216,7 +216,10 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
 
             kwargs["authority"] = authority
 
-        return URL(self._uri_reference.copy_with(**kwargs).unsplit())
+        return URL(
+            self._uri_reference.copy_with(**kwargs).unsplit(),
+            allow_relative=self.is_relative_url,
+        )
 
     def join(self, relative_url: URLTypes) -> "URL":
         """


Test patch:
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -56,6 +56,10 @@ async def body():
             headers = {"location": "/"}
             return Response(codes.SEE_OTHER, headers=headers, request=request)
 
+        elif request.url.path == "/malformed_redirect":
+            headers = {"location": "https://:443/"}
+            return Response(codes.SEE_OTHER, headers=headers, request=request)
+
         elif request.url.path == "/no_scheme_redirect":
             headers = {"location": "//example.org/"}
             return Response(codes.SEE_OTHER, headers=headers, request=request)
@@ -176,6 +180,16 @@ async def test_relative_redirect():
     assert len(response.history) == 1
 
 
+@pytest.mark.usefixtures("async_environment")
+async def test_malformed_redirect():
+    # https://github.com/encode/httpx/issues/771
+    client = AsyncClient(dispatch=MockDispatch())
+    response = await client.get("http://example.org/malformed_redirect")
+    assert response.status_code == codes.OK
+    assert response.url == URL("https://example.org/")
+    assert len(response.history) == 1
+
+
 @pytest.mark.usefixtures("async_environment")
 async def test_no_scheme_redirect():
     client = AsyncClient(dispatch=MockDispatch())