From eabf204f297f31cecb77df888ad5755b22d72c91 Mon Sep 17 00:00:00 2001 From: John Keyes Date: Fri, 20 Mar 2015 11:49:37 +0000 Subject: [PATCH 1/2] Adding HTTP error support. --- intercom/__init__.py | 32 ++++++++++------------ intercom/errors.py | 24 ++++++++++++++++ intercom/request.py | 19 ++++++++++++- tests/unit/request_spec.py | 56 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 tests/unit/request_spec.py diff --git a/intercom/__init__.py b/intercom/__init__.py index 3b099aef..03e12823 100644 --- a/intercom/__init__.py +++ b/intercom/__init__.py @@ -1,21 +1,22 @@ # -*- coding: utf-8 -*- from datetime import datetime -from .errors import ArgumentError -from .errors import HttpError # noqa +from .errors import (ArgumentError, HttpError, IntercomError, # noqa + ResourceNotFound, AuthenticationError, ServerError, BadGatewayError, + ServiceUnavailableError) # noqa from .lib.setter_property import SetterProperty from .request import Request -from .admin import Admin -from .company import Company -from .conversation import Conversation -from .event import Event -from .message import Message -from .note import Note -from .notification import Notification -from .user import User -from .segment import Segment -from .subscription import Subscription -from .tag import Tag +from .admin import Admin # noqa +from .company import Company # noqa +from .conversation import Conversation # noqa +from .event import Event # noqa +from .message import Message # noqa +from .note import Note # noqa +from .notification import Notification # noqa +from .user import User # noqa +from .segment import Segment # noqa +from .subscription import Subscription # noqa +from .tag import Tag # noqa import copy import random @@ -24,11 +25,6 @@ __version__ = '2.0.alpha' -__all__ = ( - Admin, Company, Conversation, Event, Message, Note, Notification, - Segment, Subscription, Tag, User -) - RELATED_DOCS_TEXT = "See https://github.com/jkeyes/python-intercom \ for usage examples." diff --git a/intercom/errors.py b/intercom/errors.py index bd6727e3..3554bbe5 100644 --- a/intercom/errors.py +++ b/intercom/errors.py @@ -7,3 +7,27 @@ class ArgumentError(ValueError): class HttpError(Exception): pass + + +class IntercomError(Exception): + pass + + +class ResourceNotFound(IntercomError): + pass + + +class AuthenticationError(IntercomError): + pass + + +class ServerError(IntercomError): + pass + + +class BadGatewayError(IntercomError): + pass + + +class ServiceUnavailableError(IntercomError): + pass diff --git a/intercom/request.py b/intercom/request.py index 4d01a292..a9458b46 100644 --- a/intercom/request.py +++ b/intercom/request.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from .errors import HttpError # noqa +from . import errors import json import requests @@ -32,9 +32,26 @@ def send_request_to_path(cls, method, url, auth, params=None): method, url, timeout=cls.timeout, auth=auth, **req_params) + cls.raise_errors_on_failure(resp) + if resp.content: return json.loads(resp.content) + @classmethod + def raise_errors_on_failure(cls, resp): + if resp.status_code == 404: + raise errors.ResourceNotFound('Resource Not Found') + elif resp.status_code == 401: + raise errors.AuthenticationError('Unauthorized') + elif resp.status_code == 403: + raise errors.AuthenticationError('Forbidden') + elif resp.status_code == 500: + raise errors.ServerError('Server Error') + elif resp.status_code == 502: + raise errors.BadGatewayError('Bad Gateway Error') + elif resp.status_code == 503: + raise errors.ServiceUnavailableError('Service Unavailable') + class ResourceEncoder(json.JSONEncoder): def default(self, o): diff --git a/tests/unit/request_spec.py b/tests/unit/request_spec.py new file mode 100644 index 00000000..6ffd4b0d --- /dev/null +++ b/tests/unit/request_spec.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +import httpretty +import intercom +import re +from describe import expect +from intercom import Intercom + +get = httpretty.GET +post = httpretty.POST +r = re.compile + + +class DescribeRequest: + + @httpretty.activate + def it_raises_resource_not_found(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=404) + with expect.to_raise_error(intercom.ResourceNotFound): + Intercom.get('/notes') + + @httpretty.activate + def it_raises_authentication_error_unauthorized(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=401) + with expect.to_raise_error(intercom.AuthenticationError): + Intercom.get('/notes') + + @httpretty.activate + def it_raises_authentication_error_forbidden(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=403) + with expect.to_raise_error(intercom.AuthenticationError): + Intercom.get('/notes') + + @httpretty.activate + def it_raises_server_error(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=500) + with expect.to_raise_error(intercom.ServerError): + Intercom.get('/notes') + + @httpretty.activate + def it_raises_bad_gateway_error(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=502) + with expect.to_raise_error(intercom.BadGatewayError): + Intercom.get('/notes') + + @httpretty.activate + def it_raises_service_unavailable_error(self): + httpretty.register_uri( + get, r(r'/notes$'), body='', status=503) + with expect.to_raise_error(intercom.ServiceUnavailableError): + Intercom.get('/notes') From b472fc0925e1f33aea9404e65ca97ca6d82ababd Mon Sep 17 00:00:00 2001 From: John Keyes Date: Fri, 20 Mar 2015 13:13:22 +0000 Subject: [PATCH 2/2] Adding application response error handling. --- intercom/__init__.py | 7 +- intercom/errors.py | 34 +++++++++- intercom/request.py | 48 ++++++++++++- tests/unit/request_spec.py | 133 +++++++++++++++++++++++++++++++++++++ tests/unit/user_spec.py | 17 +++++ 5 files changed, 234 insertions(+), 5 deletions(-) diff --git a/intercom/__init__.py b/intercom/__init__.py index 03e12823..1f65c9eb 100644 --- a/intercom/__init__.py +++ b/intercom/__init__.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from datetime import datetime -from .errors import (ArgumentError, HttpError, IntercomError, # noqa - ResourceNotFound, AuthenticationError, ServerError, BadGatewayError, - ServiceUnavailableError) # noqa +from .errors import (ArgumentError, AuthenticationError, # noqa + BadGatewayError, BadRequestError, HttpError, IntercomError, + MultipleMatchingUsersError, RateLimitExceeded, ResourceNotFound, + ServerError, ServiceUnavailableError, UnexpectedError) from .lib.setter_property import SetterProperty from .request import Request from .admin import Admin # noqa diff --git a/intercom/errors.py b/intercom/errors.py index 3554bbe5..f08fc432 100644 --- a/intercom/errors.py +++ b/intercom/errors.py @@ -10,7 +10,10 @@ class HttpError(Exception): class IntercomError(Exception): - pass + + def __init__(self, message=None, context=None): + super(IntercomError, self).__init__(message) + self.context = context class ResourceNotFound(IntercomError): @@ -31,3 +34,32 @@ class BadGatewayError(IntercomError): class ServiceUnavailableError(IntercomError): pass + + +class BadRequestError(IntercomError): + pass + + +class RateLimitExceeded(IntercomError): + pass + + +class MultipleMatchingUsersError(IntercomError): + pass + + +class UnexpectedError(IntercomError): + pass + + +error_codes = { + 'unauthorized': AuthenticationError, + 'forbidden': AuthenticationError, + 'bad_request': BadRequestError, + 'missing_parameter': BadRequestError, + 'parameter_invalid': BadRequestError, + 'not_found': ResourceNotFound, + 'rate_limit_exceeded': RateLimitExceeded, + 'service_unavailable': ServiceUnavailableError, + 'conflict': MultipleMatchingUsersError, +} diff --git a/intercom/request.py b/intercom/request.py index a9458b46..d76ffce8 100644 --- a/intercom/request.py +++ b/intercom/request.py @@ -35,7 +35,17 @@ def send_request_to_path(cls, method, url, auth, params=None): cls.raise_errors_on_failure(resp) if resp.content: - return json.loads(resp.content) + return cls.parse_body(resp) + + @classmethod + def parse_body(cls, resp): + try: + body = json.loads(resp.content) + except ValueError: + cls.raise_errors_on_failure(resp) + if body.get('type') == 'error.list': + cls.raise_application_errors_on_failure(body, resp.status_code) + return body @classmethod def raise_errors_on_failure(cls, resp): @@ -52,6 +62,42 @@ def raise_errors_on_failure(cls, resp): elif resp.status_code == 503: raise errors.ServiceUnavailableError('Service Unavailable') + @classmethod + def raise_application_errors_on_failure(cls, error_list_details, http_code): # noqa + # Currently, we don't support multiple errors + error_details = error_list_details['errors'][0] + error_code = error_details.get('type') + if error_code is None: + error_code = error_details.get('code') + error_context = { + 'http_code': http_code, + 'application_error_code': error_code + } + error_class = errors.error_codes.get(error_code) + if error_class is None: + # unexpected error + if error_code: + message = cls.message_for_unexpected_error_with_type( + error_details, http_code) + else: + message = cls.message_for_unexpected_error_without_type( + error_details, http_code) + error_class = errors.UnexpectedError + else: + message = error_details['message'] + raise error_class(message, error_context) + + @classmethod + def message_for_unexpected_error_with_type(cls, error_details, http_code): # noqa + error_type = error_details['type'] + message = error_details['message'] + return "The error of type '%s' is not recognized. It occurred with the message: %s and http_code: '%s'. Please contact Intercom with these details." % (error_type, message, http_code) # noqa + + @classmethod + def message_for_unexpected_error_without_type(cls, error_details, http_code): # noqa + message = error_details['message'] + return "An unexpected error occured. It occurred with the message: %s and http_code: '%s'. Please contact Intercom with these details." % (message, http_code) # noqa + class ResourceEncoder(json.JSONEncoder): def default(self, o): diff --git a/tests/unit/request_spec.py b/tests/unit/request_spec.py index 6ffd4b0d..34017099 100644 --- a/tests/unit/request_spec.py +++ b/tests/unit/request_spec.py @@ -2,9 +2,11 @@ import httpretty import intercom +import json import re from describe import expect from intercom import Intercom +from intercom import UnexpectedError get = httpretty.GET post = httpretty.POST @@ -54,3 +56,134 @@ def it_raises_service_unavailable_error(self): get, r(r'/notes$'), body='', status=503) with expect.to_raise_error(intercom.ServiceUnavailableError): Intercom.get('/notes') + + @httpretty.activate + def it_raises_an_unexpected_typed_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'hopper', + 'message': 'The first compiler.' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + try: + Intercom.get('/users') + except (UnexpectedError) as err: + assert "The error of type 'hopper' is not recognized" in err.message # noqa + expect(err.context['http_code']) == 200 + expect(err.context['application_error_code']) == 'hopper' + + @httpretty.activate + def it_raises_an_unexpected_untyped_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'message': 'UNIVAC' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + try: + Intercom.get('/users') + except (UnexpectedError) as err: + assert "An unexpected error occured." in err.message + expect(err.context['application_error_code']) is None + + @httpretty.activate + def it_raises_a_bad_request_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': None, + 'message': 'email is required' + } + ] + } + + for code in ['missing_parameter', 'parameter_invalid', 'bad_request']: + payload['errors'][0]['type'] = code + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.BadRequestError): + Intercom.get('/users') + + @httpretty.activate + def it_raises_an_authentication_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'unauthorized', + 'message': 'Your name\'s not down.' + } + ] + } + for code in ['unauthorized', 'forbidden']: + payload['errors'][0]['type'] = code + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.AuthenticationError): + Intercom.get('/users') + + @httpretty.activate + def it_raises_resource_not_found_by_type(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'not_found', + 'message': 'Waaaaally?' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.ResourceNotFound): + Intercom.get('/users') + + @httpretty.activate + def it_raises_rate_limit_exceeded(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'rate_limit_exceeded', + 'message': 'Fair use please.' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.RateLimitExceeded): + Intercom.get('/users') + + @httpretty.activate + def it_raises_a_service_unavailable_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'service_unavailable', + 'message': 'Zzzzz.' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.ServiceUnavailableError): + Intercom.get('/users') + + @httpretty.activate + def it_raises_a_multiple_matching_users_error(self): + payload = { + 'type': 'error.list', + 'errors': [ + { + 'type': 'conflict', + 'message': 'Two many cooks.' + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(intercom.MultipleMatchingUsersError): + Intercom.get('/users') diff --git a/tests/unit/user_spec.py b/tests/unit/user_spec.py index 229d10be..b389f2d4 100644 --- a/tests/unit/user_spec.py +++ b/tests/unit/user_spec.py @@ -10,7 +10,9 @@ from describe import expect from intercom.collection_proxy import CollectionProxy from intercom.lib.flat_store import FlatStore +from intercom import Intercom from intercom import User +from intercom import MultipleMatchingUsersError from intercom.utils import create_class_instance from tests.unit import test_user @@ -317,6 +319,21 @@ def it_returns_the_total_number_of_users(self): mock_count.return_value = 100 expect(100) == User.count() + @httpretty.activate + def it_raises_a_multiple_matching_users_error_when_receiving_a_conflict(self): # noqa + payload = { + 'type': 'error.list', + 'errors': [ + { + 'code': 'conflict', + 'message': 'Multiple existing users match this email address - must be more specific using user_id' # noqa + } + ] + } + httpretty.register_uri(get, r("/users"), body=json.dumps(payload)) + with expect.to_raise_error(MultipleMatchingUsersError): + Intercom.get('/users') + class DescribeIncrementingCustomAttributeFields: def before_each(self, context):