Skip to content

Commit

Permalink
Map HTTPCore exceptions (#1044)
Browse files Browse the repository at this point in the history
* Map HTTPCore exceptions

* Expose new TimeoutException
  • Loading branch information
florimondmanca authored Jul 3, 2020
1 parent fab4279 commit bacc2d1
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 38 deletions.
2 changes: 2 additions & 0 deletions httpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ResponseNotRead,
StreamConsumed,
StreamError,
TimeoutException,
TooManyRedirects,
WriteError,
WriteTimeout,
Expand Down Expand Up @@ -81,6 +82,7 @@
"StreamConsumed",
"StreamError",
"ProxyError",
"TimeoutException",
"TooManyRedirects",
"WriteError",
"WriteTimeout",
Expand Down
63 changes: 36 additions & 27 deletions httpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
125 changes: 114 additions & 11 deletions httpx/_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import typing

import httpcore
Expand Down Expand Up @@ -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...
Expand Down Expand Up @@ -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,
}
40 changes: 40 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down

0 comments on commit bacc2d1

Please sign in to comment.