Repository: falconry/falcon

Issue:
Run "after" hooks even when an error is raised
I suspect that most "after" hooks are used for formatting response bodies to fit a specific API structure.  In my case, this also applies to any exceptions raised, so it would be helpful to have the "after" hooks also be run against any `resp.body` generated as part of my custom error handler.



Discussion:
Nice idea. I think this would apply for any instance of HTTPError and for any error type for which a custom error handler is defined.

In addition to affording response formatting, this would also allow timing/tracing hooks and such.

In my case, I need to catch all errors/exceptions so the server can format things appropriately.  E.g. all of my encoding is done as a hook so if a request asked for json or xml the error handler needs to return an error in the correct format -- much easier to just keep all of that logic in a single hook rather than re-implementing it inside of each error handler.

Moving this to 0.2 milestone since I think it is unlikely to break existing apps, and is a sorely-needed enhancement.


Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -88,7 +88,7 @@ def __call__(self, env, start_response):
         req = self._request_type(env)
         resp = self._response_type()
 
-        responder, params = self._get_responder(
+        responder, params, resource = self._get_responder(
             req.path, req.method)
 
         try:
@@ -104,6 +104,7 @@ def __call__(self, env, start_response):
                 for err_type, err_handler in self._error_handlers:
                     if isinstance(ex, err_type):
                         err_handler(ex, req, resp, params)
+                        self._call_after_hooks(req, resp, resource)
                         break  # pragma: no cover
 
                 else:
@@ -118,6 +119,7 @@ def __call__(self, env, start_response):
 
         except HTTPError as ex:
             helpers.compose_error_response(req, resp, ex)
+            self._call_after_hooks(req, resp, resource)
 
         #
         # Set status and headers
@@ -194,7 +196,7 @@ def on_put(self, req, resp, thing):
 
         # Insert at the head of the list in case we get duplicate
         # adds (will cause the last one to win).
-        self._routes.insert(0, (path_template, method_map))
+        self._routes.insert(0, (path_template, method_map, resource))
 
     def add_sink(self, sink, prefix=r'/'):
         """Adds a "sink" responder to the API.
@@ -267,7 +269,7 @@ def add_error_handler(self, exception, handler=None):
                                      'member of the given exception class.')
 
         # Insert at the head of the list in case we get duplicate
-        # adds (will cause the last one to win).
+        # adds (will cause the most recently added one to win).
         self._error_handlers.insert(0, (exception, handler))
 
     # ------------------------------------------------------------------------
@@ -282,9 +284,10 @@ def _get_responder(self, path, method):
             method: HTTP method (uppercase) requested
 
         Returns:
-            A 2-member tuple consisting of a responder callable and
+            A 3-member tuple consisting of a responder callable,
             a dict containing parsed path fields (if any were specified in
-            the matching route's URI template).
+            the matching route's URI template), and a reference to the
+            responder's resource instance.
 
         Note:
             If a responder was matched to the given URI, but the HTTP
@@ -298,7 +301,7 @@ def _get_responder(self, path, method):
         """
 
         for route in self._routes:
-            path_template, method_map = route
+            path_template, method_map, resource = route
             m = path_template.match(path)
             if m:
                 params = m.groupdict()
@@ -311,6 +314,7 @@ def _get_responder(self, path, method):
                 break
         else:
             params = {}
+            resource = None
 
             for pattern, sink in self._sinks:
                 m = pattern.match(path)
@@ -322,4 +326,18 @@ def _get_responder(self, path, method):
             else:
                 responder = falcon.responders.path_not_found
 
-        return (responder, params)
+        return (responder, params, resource)
+
+    def _call_after_hooks(self, req, resp, resource):
+        """Executes each of the global "after" hooks, in turn."""
+
+        if not self._after:
+            return
+
+        for hook in self._after:
+            try:
+                hook(req, resp, resource)
+            except TypeError:
+                # NOTE(kgriffs): Catching the TypeError is a heuristic to
+                # detect old hooks that do not accept the "resource" param
+                hook(req, resp)
diff --git a/falcon/hooks.py b/falcon/hooks.py
--- a/falcon/hooks.py
+++ b/falcon/hooks.py
@@ -24,20 +24,23 @@ def before(action):
     """Decorator to execute the given action function *before* the responder.
 
     Args:
-        action (callable): A function of the form ``func(req, resp, params)``,
-            where params is a dict of URI Template field names, if any,
+        action (callable): A function of the form
+            ``func(req, resp, resource, params)``, where `resource` is a
+            reference to the resource class associated with the request,
+            and `params` is a dict of URI Template field names, if any,
             that will be passed into the resource responder as *kwargs*.
 
-            Hooks may inject extra params as needed. For example::
+            Note:
+                Hooks may inject extra params as needed. For example::
 
-                def do_something(req, resp, params):
-                    try:
-                        params['id'] = int(params['id'])
-                    except ValueError:
-                        raise falcon.HTTPBadRequest('Invalid ID',
-                                                    'ID was not valid.')
+                    def do_something(req, resp, params):
+                        try:
+                            params['id'] = int(params['id'])
+                        except ValueError:
+                            raise falcon.HTTPBadRequest('Invalid ID',
+                                                        'ID was not valid.')
 
-                    params['answer'] = 42
+                        params['answer'] = 42
 
     """
 
@@ -84,7 +87,9 @@ def after(action):
     """Decorator to execute the given action function *after* the responder.
 
     Args:
-        action (callable): A function of the form ``func(req, resp)``
+        action (callable): A function of the form
+            ``func(req, resp, resource)``, where `resource` is a
+            reference to the resource class associated with the request
 
     """
 


Test patch:
diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py
--- a/tests/test_after_hooks.py
+++ b/tests/test_after_hooks.py
@@ -143,6 +143,12 @@ def on_options(self, req, resp):
         resp.status = falcon.HTTP_501
 
 
+class FaultyResource(object):
+
+    def on_get(self, req, resp):
+        raise falcon.HTTPError(falcon.HTTP_743, 'Query failed')
+
+
 class TestHooks(testing.TestBase):
 
     def before(self):
@@ -237,6 +243,17 @@ def test_multiple_global_hooks_wrap_default_405(self):
         self.assertEqual('fluffy', self.srmock.headers_dict['X-Fluffiness'])
         self.assertEqual('cute', self.srmock.headers_dict['X-Cuteness'])
 
+    def test_global_after_hooks_run_after_exception(self):
+        self.api = falcon.API(after=[fluffiness,
+                                     resource_aware_cuteness,
+                                     Smartness()])
+
+        self.api.add_route(self.test_route, FaultyResource())
+
+        actual_body = self.simulate_request(self.test_route, decode='utf-8')
+        self.assertEqual(falcon.HTTP_743, self.srmock.status)
+        self.assertEqual(actual_body, u'fluffy and cute and smart')
+
     def test_output_validator(self):
         self.simulate_request(self.test_route)
         self.assertEqual(falcon.HTTP_723, self.srmock.status)