Repository: boto/botocore
Pull request #1113 (2016-12-21 23:53:54)

Add modeled client exceptions
Refactor of this PR: https://github.com/boto/botocore/pull/1005 that adds modeled exceptions to clients.

The main difference is that the exception classes are generated upfront as opposed to using ``__getattr__`` magic.

I did some performance testing with this script:
```python
import time
import boto3


iterations = 200
total = 0

total_start = time.time()
for _ in range(iterations):
    start = time.time()
    c = boto3.client('iam')
    end = time.time()
    total += end - start

print 'Average: ', total/iterations
print 'Total: ', time.time() - total_start
```

With the way I have it now, the client creation time is not affected as exception classes are lazily generated on accessing the ``exceptions`` property on the client.

cc @kislyuk @jamesls @JordonPhillips @stealthycoin @dstufft 



Patch:
diff --git a/.changes/next-release/feature-Exceptions-61268.json b/.changes/next-release/feature-Exceptions-61268.json
new file mode 100644
index 000000000..4fd8c1a55
--- /dev/null
+++ b/.changes/next-release/feature-Exceptions-61268.json
@@ -0,0 +1,5 @@
+{
+  "category": "Exceptions",
+  "description": "Add modeled exceptions on client via ``Client.exceptions`` property",
+  "type": "feature"
+}
diff --git a/botocore/args.py b/botocore/args.py
index da6383f6a..54f4290f1 100644
--- a/botocore/args.py
+++ b/botocore/args.py
@@ -30,11 +30,12 @@ logger = logging.getLogger(__name__)
 
 class ClientArgsCreator(object):
     def __init__(self, event_emitter, user_agent, response_parser_factory,
-                 loader):
+                 loader, exceptions_factory):
         self._event_emitter = event_emitter
         self._user_agent = user_agent
         self._response_parser_factory = response_parser_factory
         self._loader = loader
+        self._exceptions_factory = exceptions_factory
 
     def get_client_args(self, service_model, region_name, is_secure,
                         endpoint_url, verify, credentials, scoped_config,
@@ -81,7 +82,8 @@ class ClientArgsCreator(object):
             'service_model': service_model,
             'loader': self._loader,
             'client_config': new_config,
-            'partition': partition
+            'partition': partition,
+            'exceptions_factory': self._exceptions_factory
         }
 
     def compute_client_args(self, service_model, client_config,
diff --git a/botocore/client.py b/botocore/client.py
index 33de501e6..592ef177d 100644
--- a/botocore/client.py
+++ b/botocore/client.py
@@ -45,7 +45,7 @@ class ClientCreator(object):
     """Creates client objects for a service."""
     def __init__(self, loader, endpoint_resolver, user_agent, event_emitter,
                  retry_handler_factory, retry_config_translator,
-                 response_parser_factory=None):
+                 response_parser_factory=None, exceptions_factory=None):
         self._loader = loader
         self._endpoint_resolver = endpoint_resolver
         self._user_agent = user_agent
@@ -53,6 +53,7 @@ class ClientCreator(object):
         self._retry_handler_factory = retry_handler_factory
         self._retry_config_translator = retry_config_translator
         self._response_parser_factory = response_parser_factory
+        self._exceptions_factory = exceptions_factory
 
     def create_client(self, service_name, region_name, is_secure=True,
                       endpoint_url=None, verify=None,
@@ -216,7 +217,8 @@ class ClientCreator(object):
                          scoped_config, client_config, endpoint_bridge):
         args_creator = ClientArgsCreator(
             self._event_emitter, self._user_agent,
-            self._response_parser_factory, self._loader)
+            self._response_parser_factory, self._loader,
+            self._exceptions_factory)
         return args_creator.get_client_args(
             service_model, region_name, is_secure, endpoint_url,
             verify, credentials, scoped_config, client_config, endpoint_bridge)
@@ -479,7 +481,7 @@ class BaseClient(object):
 
     def __init__(self, serializer, endpoint, response_parser,
                  event_emitter, request_signer, service_model, loader,
-                 client_config, partition):
+                 client_config, partition, exceptions_factory):
         self._serializer = serializer
         self._endpoint = endpoint
         self._response_parser = response_parser
@@ -490,6 +492,8 @@ class BaseClient(object):
         self.meta = ClientMeta(event_emitter, self._client_config,
                                endpoint.host, service_model,
                                self._PY_TO_OP_NAME, partition)
+        self._exceptions_factory = exceptions_factory
+        self._exceptions = None
         self._register_handlers()
 
     def _register_handlers(self):
@@ -534,7 +538,9 @@ class BaseClient(object):
         )
 
         if http.status_code >= 300:
-            raise ClientError(parsed_response, operation_name)
+            error_code = parsed_response.get("Error", {}).get("Code")
+            error_class = self.exceptions.from_code(error_code)
+            raise error_class(parsed_response, operation_name)
         else:
             return parsed_response
 
@@ -688,6 +694,16 @@ class BaseClient(object):
         # which are the keys in the dict.
         return [xform_name(name) for name in model.waiter_names]
 
+    @property
+    def exceptions(self):
+        if self._exceptions is None:
+            self._exceptions = self._load_exceptions()
+        return self._exceptions
+
+    def _load_exceptions(self):
+        return self._exceptions_factory.create_client_exceptions(
+            self._service_model)
+
 
 class ClientMeta(object):
     """Holds additional client methods.
diff --git a/botocore/errorfactory.py b/botocore/errorfactory.py
new file mode 100644
index 000000000..05ac5c1ef
--- /dev/null
+++ b/botocore/errorfactory.py
@@ -0,0 +1,95 @@
+# Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+# http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file is
+# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific
+# language governing permissions and limitations under the License.
+from botocore.exceptions import ClientError
+from botocore.utils import get_service_module_name
+
+
+class BaseClientExceptions(object):
+    ClientError = ClientError
+
+    def __init__(self, code_to_exception):
+        """Base class for exceptions object on a client
+
+        :type code_to_exception: dict
+        :param code_to_exception: Mapping of error codes (strings) to exception
+            class that should be raised when encountering a particular
+            error code.
+        """
+        self._code_to_exception = code_to_exception
+
+    def from_code(self, error_code):
+        """Retrieves the error class based on the error code
+
+        This is helpful for identifying the exception class needing to be
+        caught based on the ClientError.parsed_reponse['Error']['Code'] value
+
+        :type error_code: string
+        :param error_code: The error code associated to a ClientError exception
+
+        :rtype: ClientError or a subclass of ClientError
+        :returns: The appropriate modeled exception class for that error
+            code. If the error code does not match any of the known
+            modeled exceptions then return a generic ClientError.
+        """
+        return self._code_to_exception.get(error_code, self.ClientError)
+
+    def __getattr__(self, name):
+        exception_cls_names = [
+            exception_cls.__name__ for exception_cls
+            in self._code_to_exception.values()
+        ]
+        raise AttributeError(
+            '%r object has no attribute %r. Valid exceptions are: %s' % (
+                self, name, ', '.join(exception_cls_names)))
+
+
+class ClientExceptionsFactory(object):
+    def __init__(self):
+        self._client_exceptions_cache = {}
+
+    def create_client_exceptions(self, service_model):
+        """Creates a ClientExceptions object for the particular service client
+
+        :type service_model: botocore.model.ServiceModel
+        :param service_model: The service model for the client
+
+        :rtype: object that subclasses from BaseClientExceptions
+        :returns: The exceptions object of a client that can be used
+            to grab the various different modeled exceptions.
+        """
+        service_name = service_model.service_name
+        if service_name not in self._client_exceptions_cache:
+            client_exceptions = self._create_client_exceptions(service_model)
+            self._client_exceptions_cache[service_name] = client_exceptions
+        return self._client_exceptions_cache[service_name]
+
+    def _create_client_exceptions(self, service_model):
+        cls_props = {}
+        code_to_exception = {}
+        for shape_name in service_model.shape_names:
+            shape = service_model.shape_for(shape_name)
+            if shape.metadata.get('exception', False):
+                exception_name = str(shape.name)
+                exception_cls = type(exception_name, (ClientError,), {})
+                code = shape.metadata.get("error", {}).get("code")
+                cls_props[exception_name] = exception_cls
+                if code:
+                    code_to_exception[code] = exception_cls
+                else:
+                    # Use the exception name if there is no explicit code
+                    # modeled
+                    code_to_exception[exception_name] = exception_cls
+        cls_name = str(get_service_module_name(service_model) + 'Exceptions')
+        client_exceptions_cls = type(
+            cls_name, (BaseClientExceptions,), cls_props)
+        return client_exceptions_cls(code_to_exception)
diff --git a/botocore/model.py b/botocore/model.py
index a33c2edbc..ff78b5416 100644
--- a/botocore/model.py
+++ b/botocore/model.py
@@ -50,7 +50,7 @@ class Shape(object):
                         'payload', 'streaming', 'timestampFormat',
                         'xmlNamespace', 'resultWrapper', 'xmlAttribute']
     METADATA_ATTRS = ['required', 'min', 'max', 'sensitive', 'enum',
-                      'idempotencyToken']
+                      'idempotencyToken', 'error', 'exception']
     MAP_TYPE = OrderedDict
 
     def __init__(self, shape_name, shape_model, shape_resolver=None):
@@ -243,6 +243,10 @@ class ServiceModel(object):
     def resolve_shape_ref(self, shape_ref):
         return self._shape_resolver.resolve_shape_ref(shape_ref)
 
+    @CachedProperty
+    def shape_names(self):
+        return list(self._service_description.get('shapes', {}))
+
     @instance_cache
     def operation_model(self, operation_name):
         try:
@@ -419,6 +423,11 @@ class OperationModel(object):
                 if 'idempotencyToken' in shape.metadata and
                 shape.metadata['idempotencyToken']]
 
+    @CachedProperty
+    def error_shapes(self):
+        shapes = self._operation_model.get("errors", [])
+        return list(self._service_model.resolve_shape_ref(s) for s in shapes)
+
     @CachedProperty
     def has_streaming_input(self):
         return self.get_streaming_input() is not None
diff --git a/botocore/session.py b/botocore/session.py
index 14794f038..4dacd7969 100644
--- a/botocore/session.py
+++ b/botocore/session.py
@@ -27,6 +27,7 @@ import botocore.credentials
 import botocore.client
 from botocore.exceptions import ConfigNotFound, ProfileNotFound
 from botocore.exceptions import UnknownServiceError, PartialCredentialsError
+from botocore.errorfactory import ClientExceptionsFactory
 from botocore import handlers
 from botocore.hooks import HierarchicalEmitter, first_non_none_response
 from botocore.loaders import create_loader
@@ -164,6 +165,7 @@ class Session(object):
         self._register_endpoint_resolver()
         self._register_event_emitter()
         self._register_response_parser_factory()
+        self._register_exceptions_factory()
 
     def _register_event_emitter(self):
         self._components.register_component('event_emitter', self._events)
@@ -190,6 +192,10 @@ class Session(object):
         self._components.register_component('response_parser_factory',
                                             ResponseParserFactory())
 
+    def _register_exceptions_factory(self):
+        self._components.register_component(
+            'exceptions_factory', ClientExceptionsFactory())
+
     def _register_builtin_handlers(self, events):
         for spec in handlers.BUILTIN_HANDLERS:
             if len(spec) == 2:
@@ -818,9 +824,11 @@ class Session(object):
         else:
             credentials = self.get_credentials()
         endpoint_resolver = self.get_component('endpoint_resolver')
+        exceptions_factory = self.get_component('exceptions_factory')
         client_creator = botocore.client.ClientCreator(
             loader, endpoint_resolver, self.user_agent(), event_emitter,
-            retryhandler, translate, response_parser_factory)
+            retryhandler, translate, response_parser_factory,
+            exceptions_factory)
         client = client_creator.create_client(
             service_name=service_name, region_name=region_name,
             is_secure=use_ssl, endpoint_url=endpoint_url, verify=verify,


Test patch:
diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py
index f36b3baec..73f5775f9 100644
--- a/tests/integration/test_client.py
+++ b/tests/integration/test_client.py
@@ -148,15 +148,45 @@ class TestCreateClients(unittest.TestCase):
                 'cloudformation', region_name='invalid region name')
 
 
-class TestClientErrorMessages(unittest.TestCase):
+class TestClientErrors(unittest.TestCase):
+    def setUp(self):
+        self.session = botocore.session.get_session()
+
     def test_region_mentioned_in_invalid_region(self):
-        session = botocore.session.get_session()
-        client = session.create_client(
+        client = self.session.create_client(
             'cloudformation', region_name='us-east-999')
         with self.assertRaisesRegexp(EndpointConnectionError,
                                      'Could not connect to the endpoint URL'):
             client.list_stacks()
 
+    def test_client_modeled_exception(self):
+        client = self.session.create_client(
+            'dynamodb', region_name='us-west-2')
+        with self.assertRaises(client.exceptions.ResourceNotFoundException):
+            client.describe_table(TableName="NonexistentTable")
+
+    def test_client_modeleded_exception_with_differing_code(self):
+        client = self.session.create_client('iam', region_name='us-west-2')
+        # The NoSuchEntityException should be raised on NoSuchEntity error
+        # code.
+        with self.assertRaises(client.exceptions.NoSuchEntityException):
+            client.get_role(RoleName="NonexistentIAMRole")
+
+    def test_raises_general_client_error_for_non_modeled_exception(self):
+        client = self.session.create_client('ec2', region_name='us-west-2')
+        try:
+            client.describe_regions(DryRun=True)
+        except client.exceptions.ClientError as e:
+            self.assertIs(e.__class__, ClientError)
+
+    def test_can_catch_client_exceptions_across_two_different_clients(self):
+        client = self.session.create_client(
+            'dynamodb', region_name='us-west-2')
+        client2 = self.session.create_client(
+            'dynamodb', region_name='us-west-2')
+        with self.assertRaises(client2.exceptions.ResourceNotFoundException):
+            client.describe_table(TableName="NonexistentTable")
+
 
 class TestClientMeta(unittest.TestCase):
     def setUp(self):
diff --git a/tests/unit/docs/__init__.py b/tests/unit/docs/__init__.py
index ef23eeca9..918cb5bd2 100644
--- a/tests/unit/docs/__init__.py
+++ b/tests/unit/docs/__init__.py
@@ -79,7 +79,8 @@ class BaseDocsTest(unittest.TestCase):
             loader=self.loader, endpoint_resolver=endpoint_resolver,
             user_agent='user-agent', event_emitter=self.events,
             retry_handler_factory=mock.Mock(),
-            retry_config_translator=mock.Mock())
+            retry_config_translator=mock.Mock(),
+            exceptions_factory=mock.Mock())
 
         self.client = self.creator.create_client('myservice', 'us-east-1')
 
diff --git a/tests/unit/test_args.py b/tests/unit/test_args.py
index 576130a70..3142b1d39 100644
--- a/tests/unit/test_args.py
+++ b/tests/unit/test_args.py
@@ -21,7 +21,7 @@ from botocore.config import Config
 
 class TestCreateClientArgs(unittest.TestCase):
     def setUp(self):
-        self.args_create = args.ClientArgsCreator(None, None, None, None)
+        self.args_create = args.ClientArgsCreator(None, None, None, None, None)
 
     def test_compute_s3_configuration(self):
         scoped_config = {}
@@ -106,10 +106,15 @@ class TestCreateClientArgs(unittest.TestCase):
         )
 
     def test_max_pool_from_client_config_forwarded_to_endpoint_creator(self):
-        args_create = args.ClientArgsCreator(mock.Mock(), None, None, None)
+        args_create = args.ClientArgsCreator(
+            mock.Mock(), None, None, None, None)
         config = botocore.config.Config(max_pool_connections=20)
         service_model = mock.Mock()
-        service_model.metadata = {'protocol': 'query'}
+        service_model.metadata = {
+            'serviceFullName': 'MyService',
+            'protocol': 'query'
+        }
+        service_model.operation_names = []
         bridge = mock.Mock()
         bridge.resolve.return_value = {
             'region_name': 'us-west-2', 'signature_version': 'v4',
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index e97097ab8..c76089541 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -27,6 +27,8 @@ from botocore.exceptions import InvalidS3AddressingStyleError
 from botocore.exceptions import NoRegionError
 from botocore.exceptions import UnknownEndpointError
 from botocore.exceptions import UnknownSignatureVersionError
+from botocore.errorfactory import ClientExceptionsFactory
+from botocore.stub import Stubber
 from botocore import exceptions
 from botocore.compat import six
 
@@ -49,6 +51,7 @@ class TestAutoGeneratedClient(unittest.TestCase):
                         'requestUri': '/',
                     },
                     'input': {'shape': 'TestOperationRequest'},
+                    'errors': [{'shape': 'TestOperationException'}],
                     'documentation': 'Documents TestOperation'
                 }
             },
@@ -63,6 +66,13 @@ class TestAutoGeneratedClient(unittest.TestCase):
                                 'documentation': 'Documents Bar'},
                     }
                 },
+                "TestOperationException": {
+                    'type': 'structure',
+                    'exception': True,
+                    'error': {
+                        'code': 'TestOperationErrorCode'
+                    }
+                },
                 'StringType': {'type': 'string'}
             }
         }
@@ -111,7 +121,8 @@ class TestAutoGeneratedClient(unittest.TestCase):
                               retry_handler_factory=None,
                               retry_config_translator=None,
                               response_parser_factory=None,
-                              endpoint_prefix=None):
+                              endpoint_prefix=None,
+                              exceptions_factory=None):
         if event_emitter is None:
             event_emitter = hooks.HierarchicalEmitter()
         if retry_handler_factory is None:
@@ -124,10 +135,12 @@ class TestAutoGeneratedClient(unittest.TestCase):
 
         if endpoint_creator is not None:
             self.endpoint_creator_cls.return_value = endpoint_creator
+        if exceptions_factory is None:
+            exceptions_factory = ClientExceptionsFactory()
         creator = client.ClientCreator(
             self.loader, self.resolver, 'user-agent', event_emitter,
             retry_handler_factory, retry_config_translator,
-            response_parser_factory)
+            response_parser_factory, exceptions_factory)
         return creator
 
     def assert_no_param_error_raised(self, client):
@@ -268,6 +281,21 @@ class TestAutoGeneratedClient(unittest.TestCase):
         self.assertEqual(service_client.meta.partition,
                          'aws-cn')
 
+    def test_client_has_exceptions_attribute(self):
+        creator = self.create_client_creator()
+        service_client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+        self.assertTrue(hasattr(service_client, 'exceptions'))
+
+    def test_client_has_modeled_exceptions(self):
+        creator = self.create_client_creator()
+        service_client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+        self.assertTrue(
+            issubclass(service_client.exceptions.TestOperationException,
+                       client.ClientError)
+        )
+
     def test_api_version_is_passed_to_loader_if_provided(self):
         creator = self.create_client_creator()
         self.endpoint.host = 'https://foo.bar'
@@ -528,20 +556,6 @@ class TestAutoGeneratedClient(unittest.TestCase):
         signer_mock.assert_called_with(
             'test_operation', request)
 
-    def test_client_makes_call_with_error(self):
-        error_response = {
-            'Error': {'Code': 'code', 'Message': 'error occurred'}
-        }
-        self.endpoint.make_request.return_value = (
-            mock.Mock(status_code=400), error_response)
-
-        creator = self.create_client_creator()
-
-        service_client = creator.create_client(
-            'myservice', 'us-west-2', credentials=self.credentials)
-        with self.assertRaises(client.ClientError):
-            service_client.test_operation(Foo='one', Bar='two')
-
     def test_client_validates_params_by_default(self):
         creator = self.create_client_creator()
 
@@ -1355,6 +1369,137 @@ class TestAutoGeneratedClient(unittest.TestCase):
         self.assertFalse(self.endpoint.make_request.called)
 
 
+class TestClientErrors(TestAutoGeneratedClient):
+    def add_error_response(self, error_response):
+        self.endpoint.make_request.return_value = (
+            mock.Mock(status_code=400), error_response)
+
+    def test_client_makes_call_with_error(self):
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error(
+                'test_operation', 'TestOperationErrorCode', 'error occurred')
+            with self.assertRaises(client.exceptions.TestOperationException):
+                client.test_operation(Foo='one', Bar='two')
+
+    def test_error_with_no_wire_code(self):
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error('test_operation', '404', 'Not Found')
+            try:
+                client.test_operation(Foo='one', Bar='two')
+            except client.exceptions.ClientError as e:
+                # This is needed becasue the error could be a subclass of
+                # ClientError.
+                # We explicitly want it to be a generic ClientError though
+                self.assertEqual(e.__class__, exceptions.ClientError)
+
+    def test_error_with_dot_separated_code(self):
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error(
+                'test_operation', 'InvalidAddress.NotFound', 'Not Found')
+            try:
+                client.test_operation(Foo='one', Bar='two')
+            except client.exceptions.ClientError as e:
+                # This is needed becasue the error could be a subclass of
+                # ClientError.
+                # We explicitly want it to be a generic ClientError though
+                self.assertEqual(e.__class__, exceptions.ClientError)
+
+    def test_error_with_empty_message(self):
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error(
+                'test_operation', 'TestOperationErrorCode')
+            with self.assertRaises(client.exceptions.TestOperationException):
+                client.test_operation(Foo='one', Bar='two')
+
+    def test_error_with_empty_code(self):
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error('test_operation')
+            try:
+                client.test_operation(Foo='one', Bar='two')
+            except client.exceptions.ClientError as e:
+                # This is needed becasue the error could be a subclass of
+                # ClientError.
+                # We explicitly want it to be a generic ClientError though
+                self.assertEqual(e.__class__, exceptions.ClientError)
+
+    def test_error_with_missing_code(self):
+        error_response = {'Error': {'Message': 'error occurred'}}
+        # The stubber is not being used because it will always populate the
+        # the message and code.
+        self.endpoint.make_request.return_value = (
+            mock.Mock(status_code=400), error_response)
+
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        try:
+            client.test_operation(Foo='one', Bar='two')
+        except client.exceptions.ClientError as e:
+            # This is needed becasue the error could be a subclass of
+            # ClientError.
+            # We explicitly want it to be a generic ClientError though
+            self.assertEqual(e.__class__, exceptions.ClientError)
+
+    def test_error_with_empty_contents(self):
+        error_response = {'Error': {}}
+        # The stubber is not being used because it will always populate the
+        # the message and code.
+        self.endpoint.make_request.return_value = (
+            mock.Mock(status_code=400), error_response)
+
+        creator = self.create_client_creator()
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        try:
+            client.test_operation(Foo='one', Bar='two')
+        except client.exceptions.ClientError as e:
+            # This is needed becasue the error could be a subclass of
+            # ClientError.
+            # We explicitly want it to be a generic ClientError though
+            self.assertEqual(e.__class__, exceptions.ClientError)
+
+    def test_exception_classes_across_clients_are_the_same(self):
+        creator = self.create_client_creator(
+            exceptions_factory=ClientExceptionsFactory())
+        client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+        client2 = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials)
+
+        with Stubber(client) as stub:
+            stub.add_client_error(
+                'test_operation', 'TestOperationErrorCode', 'error occurred')
+            try:
+                client.test_operation(Foo='one', Bar='two')
+            except client2.exceptions.TestOperationException as e:
+                # Caught exception should as well be an instance of the
+                # other client's TestOperationException
+                self.assertIsInstance(
+                    e, client.exceptions.TestOperationException)
+
+
 class TestConfig(unittest.TestCase):
     def test_can_use_args_to_construct(self):
         config = botocore.config.Config(*botocore.config.Config.OPTION_DEFAULTS.values())
diff --git a/tests/unit/test_errorfactory.py b/tests/unit/test_errorfactory.py
new file mode 100644
index 000000000..5b6f6a005
--- /dev/null
+++ b/tests/unit/test_errorfactory.py
@@ -0,0 +1,133 @@
+# Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+# http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file is
+# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific
+# language governing permissions and limitations under the License.
+from tests import unittest
+
+from botocore.exceptions import ClientError
+from botocore.errorfactory import BaseClientExceptions
+from botocore.errorfactory import ClientExceptionsFactory
+from botocore.model import ServiceModel
+
+
+class TestBaseClientExceptions(unittest.TestCase):
+    def setUp(self):
+        self.code_to_exception = {}
+        self.exceptions = BaseClientExceptions(self.code_to_exception)
+
+    def test_has_client_error(self):
+        self.assertIs(self.exceptions.ClientError, ClientError)
+
+    def test_from_code(self):
+        exception_cls = type('MyException', (ClientError,), {})
+        self.code_to_exception['MyExceptionCode'] = exception_cls
+        self.assertIs(
+            self.exceptions.from_code('MyExceptionCode'), exception_cls)
+
+    def test_from_code_nonmatch_defaults_to_client_error(self):
+        self.assertIs(
+            self.exceptions.from_code('SomeUnknownErrorCode'), ClientError)
+
+    def test_gettattr_message(self):
+        exception_cls = type('MyException', (ClientError,), {})
+        self.code_to_exception['MyExceptionCode'] = exception_cls
+        with self.assertRaisesRegexp(
+                AttributeError, 'Valid exceptions are: MyException'):
+            self.exceptions.SomeUnmodeledError
+
+
+class TestClientExceptionsFactory(unittest.TestCase):
+    def setUp(self):
+        self.model = {
+            "metadata": {
+                'endpointPrefix': 'myservice',
+                'serviceFullName': 'MyService',
+            },
+            'operations': {
+                'OperationName': {
+                    'name': 'OperationName',
+                    'errors': [
+                        {'shape': 'ExceptionMissingCode'},
+                        {'shape': 'ExceptionWithModeledCode'},
+                    ],
+                },
+                'AnotherOperationName': {
+                    'name': 'AnotherOperationName',
+                    'errors': [
+                        {'shape': 'ExceptionForAnotherOperation'},
+                        {'shape': 'ExceptionWithModeledCode'},
+                    ],
+                }
+            },
+            'shapes': {
+                'ExceptionWithModeledCode': {
+                    'type': 'structure',
+                    'members': {},
+                    'error': {
+                        'code': 'ModeledCode'
+                    },
+                    'exception': True,
+                },
+                'ExceptionMissingCode': {
+                    'type': 'structure',
+                    'members': {},
+                    'exception': True,
+                },
+                'ExceptionForAnotherOperation': {
+                    'type': 'structure',
+                    'members': {},
+                    'exception': True,
+                }
+            }
+        }
+        self.service_model = ServiceModel(self.model)
+        self.exceptions_factory = ClientExceptionsFactory()
+
+    def test_class_name(self):
+        exceptions = self.exceptions_factory.create_client_exceptions(
+            self.service_model)
+        self.assertEqual(exceptions.__class__.__name__, 'MyServiceExceptions')
+
+    def test_creates_modeled_exception(self):
+        exceptions = self.exceptions_factory.create_client_exceptions(
+            self.service_model)
+        self.assertTrue(hasattr(exceptions, 'ExceptionWithModeledCode'))
+        modeled_exception = exceptions.ExceptionWithModeledCode
+        self.assertEqual(
+            modeled_exception.__name__, 'ExceptionWithModeledCode')
+        self.assertTrue(issubclass(modeled_exception, ClientError))
+
+    def test_collects_modeled_exceptions_for_all_operations(self):
+        exceptions = self.exceptions_factory.create_client_exceptions(
+            self.service_model)
+        # Make sure exceptions were added for all operations by checking
+        # an exception only found on an a different operation.
+        self.assertTrue(hasattr(exceptions, 'ExceptionForAnotherOperation'))
+        modeled_exception = exceptions.ExceptionForAnotherOperation
+        self.assertEqual(
+            modeled_exception.__name__, 'ExceptionForAnotherOperation')
+        self.assertTrue(issubclass(modeled_exception, ClientError))
+
+    def test_creates_modeled_exception_mapping_that_has_code(self):
+        exceptions = self.exceptions_factory.create_client_exceptions(
+            self.service_model)
+        exception = exceptions.from_code('ModeledCode')
+        self.assertEqual(exception.__name__, 'ExceptionWithModeledCode')
+        self.assertTrue(issubclass(exception, ClientError))
+
+    def test_creates_modeled_exception_mapping_that_has_no_code(self):
+        exceptions = self.exceptions_factory.create_client_exceptions(
+            self.service_model)
+        # For exceptions that do not have an explicit code associated to them,
+        # the code is the name of the exception.
+        exception = exceptions.from_code('ExceptionMissingCode')
+        self.assertEqual(exception.__name__, 'ExceptionMissingCode')
+        self.assertTrue(issubclass(exception, ClientError))
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index 5d5ff6579..368792183 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -38,7 +38,9 @@ class TestServiceModel(unittest.TestCase):
                          'endpointPrefix': 'endpoint-prefix'},
             'documentation': 'Documentation value',
             'operations': {},
-            'shapes': {}
+            'shapes': {
+                'StringShape': {'type': 'string'}
+            }
         }
         self.service_model = model.ServiceModel(self.model)
 
@@ -66,6 +68,9 @@ class TestServiceModel(unittest.TestCase):
         self.assertEqual(self.service_model.documentation,
                          'Documentation value')
 
+    def test_shape_names(self):
+        self.assertEqual(self.service_model.shape_names, ['StringShape'])
+
 
 class TestOperationModelFromService(unittest.TestCase):
     def setUp(self):
@@ -188,6 +193,14 @@ class TestOperationModelFromService(unittest.TestCase):
         output_shape = operation.output_shape
         self.assertIsNone(output_shape)
 
+    def test_error_shapes(self):
+        service_model = model.ServiceModel(self.model)
+        operation = service_model.operation_model('OperationName')
+        # OperationName only has a NoSuchResourceException
+        self.assertEqual(len(operation.error_shapes), 1)
+        self.assertEqual(
+            operation.error_shapes[0].name, 'NoSuchResourceException')
+
 
 class TestOperationModelStreamingTypes(unittest.TestCase):
     def setUp(self):