diff --git a/README.md b/README.md index 7531641..3885f0c 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,27 @@ directory run: python -m pytest ``` +### Error Handling +Error handling has been added in version 2.0. Currently, the only HTTP error codes that +are handled are the ones documented on the GDAX API error section: [here](https://docs.gdax.com/?python#errors) + +- HTTP STATUS CODE 400: Raises InvalidCbproRequest +- HTTP STATUS CODE 401: Raises UnauthorizedCbproRequest +- HTTP STATUS CODE 403: Raises ForbiddenCbproRequest +- HTTP STATUS CODE 404: Raises NotFoundCbproRequest +- HTTP STATUS CODE 429: Raises CbproRateLimitRequest +- HTTP STATUS CODE 4XX: Raises UnknownCbproClientRequest +- HTTP STATUS CODE 5XX: Raises InternalErrorCbproRequest + +All HTTP requests from both the public client and authenticated client run through `_determine_response`, which +either returns the JSON body or raises the appropriate error. + +Please consider creating new Exception classes and mapping as Cbpro error states are discovered. + ## Change Log +*2.0* +- Added error handling to all HTTP requests [Error Handling Docs](#error-handling) + *1.1.2* **Current PyPI release** - Refactor project for Coinbase Pro - Major overhaul on how pagination is handled diff --git a/cbpro/__init__.py b/cbpro/__init__.py index 00937f5..a29f2e3 100644 --- a/cbpro/__init__.py +++ b/cbpro/__init__.py @@ -3,3 +3,4 @@ from cbpro.websocket_client import WebsocketClient from cbpro.order_book import OrderBook from cbpro.cbpro_auth import CBProAuth +from cbpro.exceptions import InvalidCbproRequest, UnauthorizedCbproRequest, ForbiddenCbproRequest, NotFoundCbproRequest, CbproRateLimitRequest, UnknownCbproClientRequest, InternalErrorCbproRequest diff --git a/cbpro/exceptions.py b/cbpro/exceptions.py new file mode 100644 index 0000000..70ec0c0 --- /dev/null +++ b/cbpro/exceptions.py @@ -0,0 +1,81 @@ +class CbproException(Exception): + """ + Base Coinbase Pro Exception + Raised when Bad Response returned from Coinbase Pro + See: https://docs.pro.coinbase.com/?r=1#errors + """ + + def __init__(self, message, code): + """ + :param message: Message from Coinbase Pro response + :type message: str + :param code: HTTP Code + :type code: int + """ + self._message = message + self._code = code + + @property + def message(self): + return self._message + + @message.setter + def message(self, message): + self._message = message + + @property + def code(self): + return self._code + + @message.setter + def code(self, code): + self._code = code + + +class InvalidCbproRequest(CbproException): + """ + Raised on 400 response from Coinbase Pro + """ + pass + + +class UnauthorizedCbproRequest(CbproException): + """ + Raised on 401 response from Coinbase Pro + """ + pass + + +class ForbiddenCbproRequest(CbproException): + """ + Raised on 403 response from Coinbase Pro + """ + pass + + +class NotFoundCbproRequest(CbproException): + """ + Raised on 404 response from Coinbase Pro + """ + pass + + +class CbproRateLimitRequest(CbproException): + """ + Raised on 429 response from Coinbase Pro + """ + pass + + +class UnknownCbproClientRequest(CbproException): + """ + Raised on 4XX responses not tracked + """ + pass + + +class InternalErrorCbproRequest(CbproException): + """ + Raised on 500 response from Coinbase Pro + """ + pass diff --git a/cbpro/public_client.py b/cbpro/public_client.py index e854a16..8b311b6 100644 --- a/cbpro/public_client.py +++ b/cbpro/public_client.py @@ -4,7 +4,9 @@ # # For public requests to the Coinbase exchange +from http import HTTPStatus import requests +from cbpro import exceptions class PublicClient(object): @@ -28,6 +30,54 @@ def __init__(self, api_url='https://api.pro.coinbase.com', timeout=30): self.url = api_url.rstrip('/') self.auth = None self.session = requests.Session() + self.timeout = timeout + + def _is_http_success(self, code): + # type: (int) -> bool + return code >= HTTPStatus.OK and code < HTTPStatus.MULTIPLE_CHOICES + + def _is_http_client_error(self, code): + # type: (int) -> bool + return code >= HTTPStatus.BAD_REQUEST and code < HTTPStatus.INTERNAL_SERVER_ERROR + + def _is_http_server_error(self, code): + # type: (int) -> bool + return code >= HTTPStatus.INTERNAL_SERVER_ERROR + + def _determine_response(self, response): + """ + Determines if CBPRO response is success or error + If success, returns response json + If error, raises appropriate CbproException + """ + if self._is_http_success(response.status_code): + return response.json() + elif self._is_http_client_error(response.status_code): + body = response.json() + message = body.get('message') + if response.status_code == HTTPStatus.BAD_REQUEST: + raise exceptions.InvalidCbproRequest(message, + HTTPStatus.BAD_REQUEST) + elif response.status_code == HTTPStatus.UNAUTHORIZED: + raise exceptions.UnauthorizedCbproRequest(message, + HTTPStatus.UNAUTHORIZED) + elif response.status_code == HTTPStatus.FORBIDDEN: + raise exceptions.ForbiddenCbproRequest(message, + HTTPStatus.FORBIDDEN) + elif response.status_code == HTTPStatus.NOT_FOUND: + raise exceptions.NotFoundCbproRequest(message, + HTTPStatus.NOT_FOUND) + elif response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + raise exceptions.CbproRateLimitRequest(message, + HTTPStatus.TOO_MANY_REQUESTS) + else: # Other 4XX response not yet mapped + raise exceptions.UnknownCbproClientRequest(message, + response.status_code) + + elif self._is_http_server_error(response.status_code): + body = response.json() + raise exceptions.InternalErrorCbproRequest(body.get('message'), + HTTPStatus.INTERNAL_SERVER_ERROR) def get_products(self): """Get a list of available currency pairs for trading. @@ -267,7 +317,7 @@ def _send_message(self, method, endpoint, params=None, data=None): url = self.url + endpoint r = self.session.request(method, url, params=params, data=data, auth=self.auth, timeout=30) - return r.json() + return self._determine_response(r) def _send_paginated_message(self, endpoint, params=None): """ Send API message that results in a paginated response. @@ -297,7 +347,7 @@ def _send_paginated_message(self, endpoint, params=None): url = self.url + endpoint while True: r = self.session.get(url, params=params, auth=self.auth, timeout=30) - results = r.json() + results = self._determine_response(r) for result in results: yield result # If there are no more pages, we're done. Otherwise update `after` diff --git a/contributors.txt b/contributors.txt index b1e5c49..3bab740 100644 --- a/contributors.txt +++ b/contributors.txt @@ -3,4 +3,5 @@ Leonard Lin Jeff Gibson David Caseria Paul Mestemaker -Drew Rice \ No newline at end of file +Drew Rice +Benjamin Ross diff --git a/setup.py b/setup.py index ea04785..53610c2 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ setup( name='cbpro', - version='1.1.4', + version='2.0.0', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..a4aec3c --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,23 @@ +from unittest.mock import patch +import pytest +import cbpro + + +@pytest.fixture +def client(): + return cbpro.PublicClient() + + +@pytest.mark.parametrize("code, exception",[ + (400, cbpro.exceptions.InvalidCbproRequest), + (401, cbpro.exceptions.UnauthorizedCbproRequest), + (403, cbpro.exceptions.ForbiddenCbproRequest), + (404, cbpro.exceptions.NotFoundCbproRequest), + (422, cbpro.exceptions.UnknownCbproClientRequest), + (429, cbpro.exceptions.CbproRateLimitRequest), + (500, cbpro.exceptions.InternalErrorCbproRequest)]) +@patch('requests.Session.request') +def test_cbpro_exceptions(mock_request, client, code, exception): + mock_request.return_value.status_code = code + with pytest.raises(exception): + response = client.get_products()