Skip to content

Commit

Permalink
Merge pull request #117 from prkumar/feature/v0.7.0/client-exceptions
Browse files Browse the repository at this point in the history
Add `Consumer.exceptions` property for handling client exceptions
  • Loading branch information
prkumar committed Oct 23, 2018
2 parents d185dc0 + 25c8b95 commit 82806c1
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 5 deletions.
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

0 comments on commit 82806c1

Please sign in to comment.