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

Add Consumer.exceptions property for handling client exceptions #117

Merged
merged 12 commits into from
Oct 23, 2018
39 changes: 39 additions & 0 deletions docs/source/user/clients.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,42 @@ constructing a :class:`~uplink.Consumer` instance:
Checkout `this example on GitHub
<https://github.com/prkumar/uplink/tree/master/examples/async-requests>`_
for more.

Handling Exceptions From the Underlying HTTP Client Library
===========================================================

Each :class:`~uplink.Consumer` instance has an :attr:`exceptions
<uplink.Consumer.exceptions>` property that exposes an enum of standard
HTTP client exceptions that can be handled:

.. code-block:: python

try:
repo = github.create_repo(name="myproject", auto_init=True)
except github.exceptions.ConnectionError:
# Handle client socket error:
...

This approach to handling exceptions decouples your code from the
backing HTTP client, improving code reuse and testability.

Here are the HTTP client exceptions that are exposed through this property:
- :class:`BaseClientException`: Base exception for client connection errors.
- :class:`ConnectionError`: A client socket error occurred.
- :class:`ConnectionTimeout`: The request timed out while trying to connect to the remote server.
- :class:`ServerTimeout`: The server did not send any data in the allotted amount of time.
- :class:`SSLError`: An SSL error occurred.
- :class:`InvalidURL`: URL used for fetching is malformed.

Of course, you can also explicitly catch a particular client error from
the backing client (e.g., :class:`requests.FileModeWarning`). This may
be useful for handling exceptions that are not exposed through the
:attr:`Consumer.exceptions <uplink.Consumer.exceptions>` property,
for example:

.. code-block:: python

try:
repo = github.create_repo(name="myproject", auto_init=True)
except aiohttp.ContentTypeError:
...
2 changes: 1 addition & 1 deletion docs/source/user/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -420,5 +420,5 @@ behaviors:

@response_handler(check_expected_headers) # Second, check headers
@response_handler(raise_for_status) # First, check success
class TodoApp(Consumer):
class GitHub(Consumer):
...
9 changes: 7 additions & 2 deletions tests/integration/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Local imports
from uplink import utils, clients
from uplink.clients import helpers
from uplink.clients import helpers, exceptions as client_exceptions


class MockClient(clients.interfaces.HttpClientAdapter):
def __init__(self, request):
self._mocked_request = request
self._request = _HistoryMaintainingRequest(_MockRequest(request))
self._exceptions = client_exceptions.Exceptions()

def create_request(self):
return self._request
Expand All @@ -16,9 +17,13 @@ def with_response(self, response):
return self

def with_side_effect(self, error):
self._mocked_request.side_effect = error
self._mocked_request.send.side_effect = error
return self

@property
def exceptions(self):
return self._exceptions

@property
def history(self):
return self._request.history
Expand Down
37 changes: 37 additions & 0 deletions tests/integration/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Third party imports
import pytest

# Local imports.
import uplink

Expand Down Expand Up @@ -46,3 +49,37 @@ def test_get_repo(mock_client, mock_response):

# Verify
assert expected_json == actual_json


def test_handle_client_exceptions(mock_client):
# Setup: mock client exceptions

class MockBaseClientException(Exception):
pass

class MockInvalidURL(MockBaseClientException):
pass

mock_client.exceptions.BaseClientException = MockBaseClientException
mock_client.exceptions.InvalidURL = MockInvalidURL

# Setup: instantiate service
service = GitHubService(base_url=BASE_URL, client=mock_client)

# Run: Catch base exception
mock_client.with_side_effect(MockBaseClientException)

with pytest.raises(service.exceptions.BaseClientException):
service.list_repos("prkumar")

# Run: Catch leaf exception
mock_client.with_side_effect(MockInvalidURL)

with pytest.raises(service.exceptions.InvalidURL):
service.list_repos("prkumar")

# Run: Try polymorphism
mock_client.with_side_effect(MockInvalidURL)

with pytest.raises(service.exceptions.BaseClientException):
service.list_repos("prkumar")
57 changes: 57 additions & 0 deletions tests/unit/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,33 @@ def test_request_send(self, mocker):
session_mock.request.assert_called_with(method="method", url="url")
callback.assert_called_with(session_mock.request.return_value)

def test_exceptions(self):
import requests

exceptions = requests_.RequestsClient.exceptions

with pytest.raises(exceptions.BaseClientException):
raise requests.RequestException()

with pytest.raises(exceptions.BaseClientException):
# Test polymorphism
raise requests.exceptions.InvalidURL()

with pytest.raises(exceptions.ConnectionError):
raise requests.exceptions.ConnectionError()

with pytest.raises(exceptions.ConnectionTimeout):
raise requests.exceptions.ConnectTimeout()

with pytest.raises(exceptions.ServerTimeout):
raise requests.exceptions.ReadTimeout()

with pytest.raises(exceptions.SSLError):
raise requests.exceptions.SSLError()

with pytest.raises(exceptions.InvalidURL):
raise requests.exceptions.InvalidURL()


class TestTwisted(object):
def test_init_without_client(self):
Expand Down Expand Up @@ -347,3 +374,33 @@ def test_create(self, mocker):

# Verify: session created with args
session_cls_mock.assert_called_with(*positionals, **keywords)

@requires_python34
def test_exceptions(self):
import aiohttp

exceptions = aiohttp_.AiohttpClient.exceptions

with pytest.raises(exceptions.BaseClientException):
raise aiohttp.ClientError()

with pytest.raises(exceptions.BaseClientException):
# Test polymorphism
raise aiohttp.InvalidURL("invalid")

with pytest.raises(exceptions.ConnectionError):
raise aiohttp.ClientConnectionError()

with pytest.raises(exceptions.ConnectionTimeout):
raise aiohttp.ClientConnectorError.__new__(
aiohttp.ClientConnectorError
)

with pytest.raises(exceptions.ServerTimeout):
raise aiohttp.ServerTimeoutError()

with pytest.raises(exceptions.SSLError):
raise aiohttp.ClientSSLError.__new__(aiohttp.ClientSSLError)

with pytest.raises(exceptions.InvalidURL):
raise aiohttp.InvalidURL("invalid")
22 changes: 22 additions & 0 deletions uplink/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ def __init__(
builder.auth = auth
builder.client = client
self.__session = session.Session(builder)
self.__client = builder.client

def _inject(self, hook, *more_hooks):
self.session.inject(hook, *more_hooks)
Expand Down Expand Up @@ -299,6 +300,27 @@ def __init__(self, language):
"""
return self.__session

@property
def exceptions(self):
"""
:class:`uplink.clients.exceptions.Exceptions`: An enum of
standard HTTP client exceptions that can be handled.

This property enables the handling of specific exceptions from
the backing HTTP client.

Example:

.. code-block:: python

try:
github.get_user(user_id)
except github.exceptions.ServerTimeout:
# Handle the timeout of the request
...
"""
return self.__client.exceptions


def build(service_cls, *args, **kwargs):
name = service_cls.__name__
Expand Down
13 changes: 12 additions & 1 deletion uplink/clients/aiohttp_.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
aiohttp = None

# Local imports
from uplink.clients import helpers, interfaces, register
from uplink.clients import exceptions, helpers, interfaces, register


def threaded_callback(callback):
Expand Down Expand Up @@ -54,6 +54,8 @@ class AiohttpClient(interfaces.HttpClientAdapter):
will be created.
"""

exceptions = exceptions.Exceptions()

# TODO: Update docstrings to include aiohttp constructor parameters.

__ARG_SPEC = collections.namedtuple("__ARG_SPEC", "args kwargs")
Expand Down Expand Up @@ -200,3 +202,12 @@ def shutdown(self, wait=True):
self._loop.call_soon_threadsafe(self._loop.stop)
if wait: # pragma: no cover
self._thread.join()


# === Register client exceptions === #
AiohttpClient.exceptions.BaseClientException = aiohttp.ClientError
AiohttpClient.exceptions.ConnectionError = aiohttp.ClientConnectionError
AiohttpClient.exceptions.ConnectionTimeout = aiohttp.ClientConnectorError
AiohttpClient.exceptions.ServerTimeout = aiohttp.ServerTimeoutError
AiohttpClient.exceptions.SSLError = aiohttp.ClientSSLError
AiohttpClient.exceptions.InvalidURL = aiohttp.InvalidURL
27 changes: 27 additions & 0 deletions uplink/clients/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class _UnmappedClientException(BaseException):
pass


class Exceptions(object):
"""Enum of standard HTTP client exceptions."""

BaseClientException = _UnmappedClientException
"""Base class for client errors."""

ConnectionError = _UnmappedClientException
"""A connection error occurred."""

ConnectionTimeout = _UnmappedClientException
"""The request timed out while trying to connect to the remote server.

Requests that produce this error are typically safe to retry.
"""

ServerTimeout = _UnmappedClientException
"""The server did not send any data in the allotted amount of time."""

SSLError = _UnmappedClientException
"""An SSL error occurred."""

InvalidURL = _UnmappedClientException
"""The URL provided was somehow invalid."""
15 changes: 15 additions & 0 deletions uplink/clients/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
# Local imports
from uplink.clients import exceptions


class HttpClientAdapter(object):
"""An adapter of an HTTP client library."""

__exceptions = exceptions.Exceptions()

def create_request(self):
raise NotImplementedError

@property
def exceptions(self):
"""
uplink.clients.exceptions.Exceptions: An enum of standard HTTP
client errors that have been mapped to client specific
exceptions.
"""
return self.__exceptions


class Request(object):
def send(self, method, url, extras):
Expand Down
13 changes: 12 additions & 1 deletion uplink/clients/requests_.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import requests

# Local imports
from uplink.clients import helpers, interfaces, register
from uplink.clients import exceptions, helpers, interfaces, register


class RequestsClient(interfaces.HttpClientAdapter):
Expand All @@ -20,6 +20,8 @@ class RequestsClient(interfaces.HttpClientAdapter):
created.
"""

exceptions = exceptions.Exceptions()

def __init__(self, session=None, **kwargs):
if session is None:
session = self._create_session(**kwargs)
Expand Down Expand Up @@ -57,3 +59,12 @@ def send(self, method, url, extras):

def add_callback(self, callback):
self._callback = callback


# === Register client exceptions === #
RequestsClient.exceptions.BaseClientException = requests.RequestException
RequestsClient.exceptions.ConnectionError = requests.ConnectionError
RequestsClient.exceptions.ConnectionTimeout = requests.ConnectTimeout
RequestsClient.exceptions.ServerTimeout = requests.ReadTimeout
RequestsClient.exceptions.SSLError = requests.exceptions.SSLError
RequestsClient.exceptions.InvalidURL = requests.exceptions.InvalidURL