Repository: encode/httpx

Issue:
Map `rfc3986.exceptions.InvalidAuthority` to `httpx.InvalidURL`
We're wrapping our underlying `h11`/`h2` library exception classes, but we're currently missing an `rfc3986` case...

We should reintroduce the `InvalidURL` exception for URLs that really are improperly formed and *cannot* be parsed (not our previous case of "Invalid because it's not http/https").

The only case I can see that we need to map is `rfc3986.exceptions.InvalidAuthority`, although perhaps I'm missing some other cases that `rfc3986` can raise? We ought to wrap our single `rfc3986.iri_reference(...)` usage, and map the `rfc3986` exception to our own exception class.

```python
>>> httpx.URL('https://😇/')
Traceback (most recent call last):
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/rfc3986/iri.py", line 125, in idna_encoder
    std3_rules=True)
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/idna/core.py", line 360, in encode
    s = alabel(label)
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/idna/core.py", line 281, in alabel
    check_label(label)
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/idna/core.py", line 261, in check_label
    raise InvalidCodepoint('Codepoint {0} at position {1} of {2} not allowed'.format(_unot(cp_value), pos+1, repr(label)))
idna.core.InvalidCodepoint: Codepoint U+1F607 at position 3 of 'ðð😇' not allowed

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/httpx/_models.py", line 69, in __init__
    self._uri_reference = rfc3986.api.iri_reference(url).encode()
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/rfc3986/iri.py", line 133, in encode
    for part in self.host.split(".")])
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/rfc3986/iri.py", line 133, in <listcomp>
    for part in self.host.split(".")])
  File "/Users/tomchristie/Temp/venv/lib/python3.7/site-packages/rfc3986/iri.py", line 127, in idna_encoder
    raise exceptions.InvalidAuthority(self.authority)
rfc3986.exceptions.InvalidAuthority: The authority (ðð😇) is not valid.
```

So, let's change from...

```python
self._uri_reference = rfc3986.iri_reference(url).encode()
```

To...

```python
try:
    self._uri_reference = rfc3986.iri_reference(url).encode()
except rfc3986.exceptions.InvalidAuthority as exc:
    raise httpx.InvalidURL(message=str(exc)) from None
```

We *don't* want this to subclass `RequestException`, because it *isn't* necessarily. The user might be using a base `httpx.URL()`.

We then possibly also want to wrap the `url = URL(location)` usage in `_redirect_url` to wrap `httpx.InvalidURL` and instead raise `httpx.RemoteProtocolError("Invalid URL in location header: {message}")`, since if a parse error occurs at that point, then it *is* a request error. (We *do* have an associated request at this point, and it is due to a remote server error, rather than invalid user input.)

I'll label this as "refactor" since it's such a minor edge bit of API.


Discussion:

Resolving patch:
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -19,6 +19,8 @@
 from ._content_streams import ContentStream
 from ._exceptions import (
     HTTPCORE_EXC_MAP,
+    InvalidURL,
+    RemoteProtocolError,
     RequestBodyUnavailable,
     TooManyRedirects,
     map_exceptions,
@@ -340,7 +342,12 @@ def _redirect_url(self, request: Request, response: Response) -> URL:
         """
         location = response.headers["Location"]
 
-        url = URL(location)
+        try:
+            url = URL(location)
+        except InvalidURL as exc:
+            raise RemoteProtocolError(
+                f"Invalid URL in location header: {exc}.", request=request
+            ) from None
 
         # Handle malformed 'Location' headers that are "absolute" form, have no host.
         # See: https://github.com/encode/httpx/issues/771
diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py
--- a/httpx/_exceptions.py
+++ b/httpx/_exceptions.py
@@ -23,6 +23,7 @@
     + TooManyRedirects
     + RequestBodyUnavailable
   x HTTPStatusError
+* InvalidURL
 * NotRedirectResponse
 * CookieConflict
 * StreamError
@@ -230,6 +231,15 @@ def __init__(
         self.response = response
 
 
+class InvalidURL(Exception):
+    """
+    URL is improperly formed or cannot be parsed.
+    """
+
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+
 class NotRedirectResponse(Exception):
     """
     Response was not a redirect response.
@@ -323,14 +333,6 @@ def __init__(self) -> None:
         super().__init__(message)
 
 
-# The `InvalidURL` class is no longer required. It was being used to enforce only
-# 'http'/'https' URLs being requested, but is now treated instead at the
-# transport layer using `UnsupportedProtocol()`.`
-
-# We are currently still exposing this class, but it will be removed in 1.0.
-InvalidURL = UnsupportedProtocol
-
-
 @contextlib.contextmanager
 def map_exceptions(
     mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]],
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -11,6 +11,7 @@
 
 import chardet
 import rfc3986
+import rfc3986.exceptions
 
 from .__version__ import __version__
 from ._content_streams import ByteStream, ContentStream, encode
@@ -25,6 +26,7 @@
 from ._exceptions import (
     CookieConflict,
     HTTPStatusError,
+    InvalidURL,
     NotRedirectResponse,
     RequestNotRead,
     ResponseClosed,
@@ -57,7 +59,11 @@
 class URL:
     def __init__(self, url: URLTypes = "", params: QueryParamTypes = None) -> None:
         if isinstance(url, str):
-            self._uri_reference = rfc3986.iri_reference(url).encode()
+            try:
+                self._uri_reference = rfc3986.iri_reference(url).encode()
+            except rfc3986.exceptions.InvalidAuthority as exc:
+                raise InvalidURL(message=str(exc)) from None
+
             if self.is_absolute_url:
                 # We don't want to normalize relative URLs, since doing so
                 # removes any leading `../` portion.
@@ -183,7 +189,7 @@ 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())
 
     def join(self, 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
@@ -10,6 +10,7 @@
     AsyncClient,
     Client,
     NotRedirectResponse,
+    RemoteProtocolError,
     RequestBodyUnavailable,
     TooManyRedirects,
     UnsupportedProtocol,
@@ -75,6 +76,11 @@ async def body():
             headers = [(b"location", b"https://:443/")]
             return b"HTTP/1.1", status_code, b"See Other", headers, ByteStream(b"")
 
+        elif path == b"/invalid_redirect":
+            status_code = codes.SEE_OTHER
+            headers = [(b"location", "https://😇/".encode("utf-8"))]
+            return b"HTTP/1.1", status_code, b"See Other", headers, ByteStream(b"")
+
         elif path == b"/no_scheme_redirect":
             status_code = codes.SEE_OTHER
             headers = [(b"location", b"//example.org/")]
@@ -249,6 +255,13 @@ async def test_malformed_redirect():
     assert len(response.history) == 1
 
 
+@pytest.mark.usefixtures("async_environment")
+async def test_invalid_redirect():
+    client = AsyncClient(transport=AsyncMockTransport())
+    with pytest.raises(RemoteProtocolError):
+        await client.get("http://example.org/invalid_redirect")
+
+
 @pytest.mark.usefixtures("async_environment")
 async def test_no_scheme_redirect():
     client = AsyncClient(transport=AsyncMockTransport())
diff --git a/tests/models/test_url.py b/tests/models/test_url.py
--- a/tests/models/test_url.py
+++ b/tests/models/test_url.py
@@ -1,6 +1,6 @@
 import pytest
 
-from httpx import URL
+from httpx import URL, InvalidURL
 
 
 @pytest.mark.parametrize(
@@ -185,3 +185,8 @@ def test_url_copywith_for_authority():
     for k, v in copy_with_kwargs.items():
         assert getattr(new, k) == v
     assert str(new) == "https://username:password@example.net:444"
+
+
+def test_url_invalid():
+    with pytest.raises(InvalidURL):
+        URL("https://😇/")
diff --git a/tests/test_api.py b/tests/test_api.py
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -69,5 +69,5 @@ def test_stream(server):
 
 
 def test_get_invalid_url():
-    with pytest.raises(httpx.InvalidURL):
+    with pytest.raises(httpx.UnsupportedProtocol):
         httpx.get("invalid://example.org")