diff --git a/httpx/__init__.py b/httpx/__init__.py index 7a2b8a96c8..155ea5c578 100644 --- a/httpx/__init__.py +++ b/httpx/__init__.py @@ -25,6 +25,7 @@ ResponseNotRead, StreamConsumed, StreamError, + TimeoutException, TooManyRedirects, WriteError, WriteTimeout, @@ -81,6 +82,7 @@ "StreamConsumed", "StreamError", "ProxyError", + "TimeoutException", "TooManyRedirects", "WriteError", "WriteTimeout", diff --git a/httpx/_client.py b/httpx/_client.py index c2a485fdf5..fd9c1e54d9 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -18,7 +18,14 @@ 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 @@ def send_single_request(self, request: Request, timeout: Timeout) -> Response: 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 @@ async def send_single_request( 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 diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py index d8b3c8b3cf..ae07ec56ad 100644 --- a/httpx/_exceptions.py +++ b/httpx/_exceptions.py @@ -1,3 +1,4 @@ +import contextlib import typing import httpcore @@ -28,25 +29,87 @@ def request(self) -> "Request": # 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, +} diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 1ce71e84ed..34a1752596 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -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: