]> git.ipfire.org Git - thirdparty/httpx.git/commitdiff
Map HTTPCore exceptions (#1044)
authorFlorimond Manca <florimond.manca@gmail.com>
Fri, 3 Jul 2020 13:56:10 +0000 (15:56 +0200)
committerGitHub <noreply@github.com>
Fri, 3 Jul 2020 13:56:10 +0000 (15:56 +0200)
* Map HTTPCore exceptions

* Expose new TimeoutException

httpx/__init__.py
httpx/_client.py
httpx/_exceptions.py
tests/test_exceptions.py

index 7a2b8a96c8b00fa33df21293ceabf43a057dc2a2..155ea5c57802ba44fd6152fe6603fff861acbb79 100644 (file)
@@ -25,6 +25,7 @@ from ._exceptions import (
     ResponseNotRead,
     StreamConsumed,
     StreamError,
+    TimeoutException,
     TooManyRedirects,
     WriteError,
     WriteTimeout,
@@ -81,6 +82,7 @@ __all__ = [
     "StreamConsumed",
     "StreamError",
     "ProxyError",
+    "TimeoutException",
     "TooManyRedirects",
     "WriteError",
     "WriteTimeout",
index c2a485fdf509acd73040a31971653a23e4823e49..fd9c1e54d9f4a609ab057e7cea89d80a482e3137 100644 (file)
@@ -18,7 +18,14 @@ from ._config import (
     UnsetType,
 )
 from ._content_streams import ContentStream
-from ._exceptions import HTTPError, InvalidURL, RequestBodyUnavailable, TooManyRedirects
+from ._exceptions import (
+    HTTPCORE_EXC_MAP,
+    HTTPError,
+    InvalidURL,
+    RequestBodyUnavailable,
+    TooManyRedirects,
+    map_exceptions,
+)
 from ._models import URL, Cookies, Headers, Origin, QueryParams, Request, Response
 from ._status_codes import codes
 from ._transports.asgi import ASGITransport
@@ -705,19 +712,20 @@ class Client(BaseClient):
         transport = self.transport_for_url(request.url)
 
         try:
-            (
-                http_version,
-                status_code,
-                reason_phrase,
-                headers,
-                stream,
-            ) = transport.request(
-                request.method.encode(),
-                request.url.raw,
-                headers=request.headers.raw,
-                stream=request.stream,
-                timeout=timeout.as_dict(),
-            )
+            with map_exceptions(HTTPCORE_EXC_MAP):
+                (
+                    http_version,
+                    status_code,
+                    reason_phrase,
+                    headers,
+                    stream,
+                ) = transport.request(
+                    request.method.encode(),
+                    request.url.raw,
+                    headers=request.headers.raw,
+                    stream=request.stream,
+                    timeout=timeout.as_dict(),
+                )
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
             # there'a already a request attached in the case of
@@ -1255,19 +1263,20 @@ class AsyncClient(BaseClient):
         transport = self.transport_for_url(request.url)
 
         try:
-            (
-                http_version,
-                status_code,
-                reason_phrase,
-                headers,
-                stream,
-            ) = await transport.request(
-                request.method.encode(),
-                request.url.raw,
-                headers=request.headers.raw,
-                stream=request.stream,
-                timeout=timeout.as_dict(),
-            )
+            with map_exceptions(HTTPCORE_EXC_MAP):
+                (
+                    http_version,
+                    status_code,
+                    reason_phrase,
+                    headers,
+                    stream,
+                ) = await transport.request(
+                    request.method.encode(),
+                    request.url.raw,
+                    headers=request.headers.raw,
+                    stream=request.stream,
+                    timeout=timeout.as_dict(),
+                )
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
             # there'a already a request attached in the case of
index d8b3c8b3cfd8007d4fbbacbd7e142e8e6dff120b..ae07ec56ad23b62c62aaf5873e272fa840d51f15 100644 (file)
@@ -1,3 +1,4 @@
+import contextlib
 import typing
 
 import httpcore
@@ -28,25 +29,87 @@ class HTTPError(Exception):
 
 # Timeout exceptions...
 
-ConnectTimeout = httpcore.ConnectTimeout
-ReadTimeout = httpcore.ReadTimeout
-WriteTimeout = httpcore.WriteTimeout
-PoolTimeout = httpcore.PoolTimeout
+
+class TimeoutException(HTTPError):
+    """
+    The base class for timeout errors.
+
+    An operation has timed out.
+    """
+
+
+class ConnectTimeout(TimeoutException):
+    """
+    Timed out while connecting to the host.
+    """
+
+
+class ReadTimeout(TimeoutException):
+    """
+    Timed out while receiving data from the host.
+    """
+
+
+class WriteTimeout(TimeoutException):
+    """
+    Timed out while sending data to the host.
+    """
+
+
+class PoolTimeout(TimeoutException):
+    """
+    Timed out waiting to acquire a connection from the pool.
+    """
 
 
 # Core networking exceptions...
 
-NetworkError = httpcore.NetworkError
-ReadError = httpcore.ReadError
-WriteError = httpcore.WriteError
-ConnectError = httpcore.ConnectError
-CloseError = httpcore.CloseError
+
+class NetworkError(HTTPError):
+    """
+    The base class for network-related errors.
+
+    An error occurred while interacting with the network.
+    """
+
+
+class ReadError(NetworkError):
+    """
+    Failed to receive data from the network.
+    """
+
+
+class WriteError(NetworkError):
+    """
+    Failed to send data through the network.
+    """
+
+
+class ConnectError(NetworkError):
+    """
+    Failed to establish a connection.
+    """
+
+
+class CloseError(NetworkError):
+    """
+    Failed to close a connection.
+    """
 
 
 # Other transport exceptions...
 
-ProxyError = httpcore.ProxyError
-ProtocolError = httpcore.ProtocolError
+
+class ProxyError(HTTPError):
+    """
+    An error occurred while proxying a request.
+    """
+
+
+class ProtocolError(HTTPError):
+    """
+    A protocol was violated by the server.
+    """
 
 
 # HTTP exceptions...
@@ -138,3 +201,43 @@ class CookieConflict(HTTPError):
     """
     Attempted to lookup a cookie by name, but multiple cookies existed.
     """
+
+
+@contextlib.contextmanager
+def map_exceptions(
+    mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]]
+) -> typing.Iterator[None]:
+    try:
+        yield
+    except Exception as exc:
+        mapped_exc = None
+
+        for from_exc, to_exc in mapping.items():
+            if not isinstance(exc, from_exc):
+                continue
+            # We want to map to the most specific exception we can find.
+            # Eg if `exc` is an `httpcore.ReadTimeout`, we want to map to
+            # `httpx.ReadTimeout`, not just `httpx.TimeoutException`.
+            if mapped_exc is None or issubclass(to_exc, mapped_exc):
+                mapped_exc = to_exc
+
+        if mapped_exc is None:
+            raise
+
+        raise mapped_exc(exc) from None
+
+
+HTTPCORE_EXC_MAP = {
+    httpcore.TimeoutException: TimeoutException,
+    httpcore.ConnectTimeout: ConnectTimeout,
+    httpcore.ReadTimeout: ReadTimeout,
+    httpcore.WriteTimeout: WriteTimeout,
+    httpcore.PoolTimeout: PoolTimeout,
+    httpcore.NetworkError: NetworkError,
+    httpcore.ConnectError: ConnectError,
+    httpcore.ReadError: ReadError,
+    httpcore.WriteError: WriteError,
+    httpcore.CloseError: CloseError,
+    httpcore.ProxyError: ProxyError,
+    httpcore.ProtocolError: ProtocolError,
+}
index 1ce71e84ed892c3acee847e65360d07bc8bccd37..34a1752596b6082a29b5434a8f43c3ce35e03099 100644 (file)
@@ -1,6 +1,46 @@
+from typing import Any
+
+import httpcore
 import pytest
 
 import httpx
+from httpx._exceptions import HTTPCORE_EXC_MAP
+
+
+def test_httpcore_all_exceptions_mapped() -> None:
+    """
+    All exception classes exposed by HTTPCore are properly mapped to an HTTPX-specific
+    exception class.
+    """
+    not_mapped = [
+        value
+        for name, value in vars(httpcore).items()
+        if isinstance(value, type)
+        and issubclass(value, Exception)
+        and value not in HTTPCORE_EXC_MAP
+    ]
+
+    if not_mapped:
+        pytest.fail(f"Unmapped httpcore exceptions: {not_mapped}")
+
+
+def test_httpcore_exception_mapping() -> None:
+    """
+    HTTPCore exception mapping works as expected.
+    """
+
+    # Make sure we don't just map to `NetworkError`.
+    with pytest.raises(httpx.ConnectError):
+        httpx.get("http://doesnotexist")
+
+    # Make sure it also works with custom transports.
+    class MockTransport(httpcore.SyncHTTPTransport):
+        def request(self, *args: Any, **kwargs: Any) -> Any:
+            raise httpcore.ProtocolError()
+
+    client = httpx.Client(transport=MockTransport())
+    with pytest.raises(httpx.ProtocolError):
+        client.get("http://testserver")
 
 
 def test_httpx_exceptions_exposed() -> None: