Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map HTTPCore exceptions #1044

Merged
merged 4 commits into from
Jul 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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