diff --git a/docs/source/user/clients.rst b/docs/source/user/clients.rst index b58c41ba..8a359c58 100644 --- a/docs/source/user/clients.rst +++ b/docs/source/user/clients.rst @@ -79,3 +79,42 @@ constructing a :class:`~uplink.Consumer` instance: Checkout `this example on GitHub `_ for more. + +Handling Exceptions From the Underlying HTTP Client Library +=========================================================== + +Each :class:`~uplink.Consumer` instance has an :attr:`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 ` property, +for example: + +.. code-block:: python + + try: + repo = github.create_repo(name="myproject", auto_init=True) + except aiohttp.ContentTypeError: + ... diff --git a/docs/source/user/quickstart.rst b/docs/source/user/quickstart.rst index 476d97ba..783d9e06 100644 --- a/docs/source/user/quickstart.rst +++ b/docs/source/user/quickstart.rst @@ -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): ... diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 13f061cd..164201b8 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -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 @@ -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 diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index 3b6ef8c2..7c354e0b 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -1,3 +1,6 @@ +# Third party imports +import pytest + # Local imports. import uplink @@ -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") diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py index 3431fa7d..5b158613 100644 --- a/tests/unit/test_clients.py +++ b/tests/unit/test_clients.py @@ -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): @@ -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") diff --git a/uplink/builder.py b/uplink/builder.py index d7dabf85..7aa84779 100644 --- a/uplink/builder.py +++ b/uplink/builder.py @@ -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) @@ -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__ diff --git a/uplink/clients/aiohttp_.py b/uplink/clients/aiohttp_.py index e57c0fa8..8a70250c 100644 --- a/uplink/clients/aiohttp_.py +++ b/uplink/clients/aiohttp_.py @@ -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): @@ -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") @@ -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 diff --git a/uplink/clients/exceptions.py b/uplink/clients/exceptions.py new file mode 100644 index 00000000..cc7795d5 --- /dev/null +++ b/uplink/clients/exceptions.py @@ -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.""" diff --git a/uplink/clients/interfaces.py b/uplink/clients/interfaces.py index 275fe41b..a264d45c 100644 --- a/uplink/clients/interfaces.py +++ b/uplink/clients/interfaces.py @@ -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): diff --git a/uplink/clients/requests_.py b/uplink/clients/requests_.py index f8a5307a..f30c21db 100644 --- a/uplink/clients/requests_.py +++ b/uplink/clients/requests_.py @@ -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): @@ -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) @@ -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