Repository: falconry/falcon

Issue:
feat: Add a way for Middleware process_request() to indicate whether routing should proceed or the response should just be rendered immediately
We can either set an attribute on `resp`, or simply support returning a truthy value to indicate whether or not the routing should be short-circuited. Other ideas?

Should we also do this for process_resource()?


Discussion:
Yes please.  We would like this too so that we can have middleware that does caching and doesn't proceed to the actual resource if the response is already cached.
Why wouldn't it be preferred to raise an `HTTPError` or `HTTPStatus`? Raising an exception is a really nice pattern for short-circuiting handlers and middleware.
@mattgiles raising an exception is very handy for short-circuiting request processing indeed.
However, there are both philosophic and technical reasons why it is not always the preferred way.

First, in Python (and many other languages) raising an exception signals that something unusual or unexpected has happened as opposed to the ordinary case. If, for instance, CORS middleware is rendering a response to ``OPTIONS``, it is completely normal behaviour, not any exception in the program logic. Furthermore, these ``HTTPStatus`` exceptions would be handled in the API's error handlers registered with ``add_error_handler`` which might confuse the user if he or she is already handling any ``HTTPStatus`` exceptions this way.

Second, short-circuiting the response has a slightly different meaning in the request processing chain. The response object is not created solely by the last middleware short-circuiting it, but potentially modified by earlier middlewares, hooks etc in the chain, whereas raising an ``HTTPStatus`` requires you to craft it from the scratch. What is more, ``HTTPStatus`` does not provide the same interface as ``Response`` does. For instance, suppose you have some imaginary cache middleware returning a cached version of the response which is streamed from an internal caching server. Right now you can only provide *body*, *headers* and *status*, but not an equivalent of ``resp.stream``. The same goes for many utility properties, although you can set many of the through headers, but you lose the convenient interface. Sure, maybe ``HTTPStatus`` could be redesigned to wrap a ``Response`` somehow, but it is IMHO valid to question the pattern itself.
A comment on the issue itself. It would feel very natural supporting a truthy value to indicate whether or not the routing should be short-circuited. However, we would need to find an alternate way (such as an attribute on ``resp``) if we further pursue https://github.com/falconry/falcon/issues/1047 .
@vytas7 totally. That's super reasonable, and makes sense on several counts. I was curious, and appreciate the thoughtful response.

To elaborate on my curiosity. I don't know if I agree that, in Python, exceptions are idiomatic only in cases where something has gone wrong. Python is also *unlike* many languages insofar as its exception handling can be used proactively to manage the flow of control.

Indeed, part of what I find so elegant about falcon is the main loop in `API.__call__`, a prominent feature of which is the explicit handling of both `HTTPError` and `HTTPStatus` as exception classes. The original idea might have been to lean in to the semantic similarity between HTTP error codes and program exceptions -- but that choice being made, it's no surprise that the broader identity of HTTP status codes and exceptions was embraced.

It's an intuitive and simple pattern: falcon provides a well-structured flow for requests and responses to traverse, but if at any point in that flow I determine exactly what the content of the response should be, I can jump out. Because this seems to be exactly the situation this issue is considering, I guess I was curious why it would be pursued by other means.

As you point out, these exception classes do not do what you need them to in order to address this issue in its particulars. But, as you also point out, they could -- and in a way which seems harmonious with #1047.
I'll go ahead and take a stab at this.

Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -210,6 +210,7 @@ def __call__(self, env, start_response):  # noqa: C901
         req = self._request_type(env, options=self.req_options)
         resp = self._response_type(options=self.resp_options)
         resource = None
+        responder = None
         params = {}
 
         dependent_mw_resp_stack = []
@@ -228,21 +229,24 @@ def __call__(self, env, start_response):  # noqa: C901
                 if self._independent_middleware:
                     for process_request in mw_req_stack:
                         process_request(req, resp)
+                        if resp.complete:
+                            break
                 else:
                     for process_request, process_response in mw_req_stack:
-                        if process_request:
+                        if process_request and not resp.complete:
                             process_request(req, resp)
                         if process_response:
                             dependent_mw_resp_stack.insert(0, process_response)
 
-                # NOTE(warsaw): Moved this to inside the try except
-                # because it is possible when using object-based
-                # traversal for _get_responder() to fail.  An example is
-                # a case where an object does not have the requested
-                # next-hop child resource. In that case, the object
-                # being asked to dispatch to its child will raise an
-                # HTTP exception signalling the problem, e.g. a 404.
-                responder, params, resource, req.uri_template = self._get_responder(req)
+                if not resp.complete:
+                    # NOTE(warsaw): Moved this to inside the try except
+                    # because it is possible when using object-based
+                    # traversal for _get_responder() to fail.  An example is
+                    # a case where an object does not have the requested
+                    # next-hop child resource. In that case, the object
+                    # being asked to dispatch to its child will raise an
+                    # HTTP exception signalling the problem, e.g. a 404.
+                    responder, params, resource, req.uri_template = self._get_responder(req)
             except Exception as ex:
                 if not self._handle_exception(req, resp, ex, params):
                     raise
@@ -251,13 +255,19 @@ def __call__(self, env, start_response):  # noqa: C901
                     # NOTE(kgriffs): If the request did not match any
                     # route, a default responder is returned and the
                     # resource is None. In that case, we skip the
-                    # resource middleware methods.
-                    if resource is not None:
+                    # resource middleware methods. Resource will also be
+                    # None when a middleware method already set
+                    # resp.complete to True.
+                    if resource:
                         # Call process_resource middleware methods.
                         for process_resource in mw_rsrc_stack:
                             process_resource(req, resp, resource, params)
+                            if resp.complete:
+                                break
+
+                    if not resp.complete:
+                        responder(req, resp, **params)
 
-                    responder(req, resp, **params)
                     req_succeeded = True
                 except Exception as ex:
                     if not self._handle_exception(req, resp, ex, params):
@@ -485,12 +495,12 @@ def add_sink(self, sink, prefix=r'/'):
         self._sinks.insert(0, (prefix, sink))
 
     def add_error_handler(self, exception, handler=None):
-        """Register a handler for a given exception error type.
+        """Register a handler for a given exception type.
 
-        Error handlers may be registered for any type, including
-        :class:`~.HTTPError`. This feature provides a central location
-        for logging and otherwise handling exceptions raised by
-        responders, hooks, and middleware components.
+        Error handlers may be registered for any exception type, including
+        :class:`~.HTTPError` or :class:`~.HTTPStatus`. This feature
+        provides a central location for logging and otherwise handling
+        exceptions raised by responders, hooks, and middleware components.
 
         A handler can raise an instance of :class:`~.HTTPError` or
         :class:`~.HTTPStatus` to communicate information about the issue to
@@ -505,6 +515,13 @@ def add_error_handler(self, exception, handler=None):
         standard ``Exception`` type) should be added first, to avoid
         masking more specific handlers for subclassed types.
 
+        .. Note::
+
+            By default, the framework installs two handlers, one for
+            :class:`~.HTTPError` and one for :class:`~.HTTPStatus`. These can
+            be overridden by adding a custom error handler method for the
+            exception type in question.
+
         Args:
             exception (type): Whenever an error occurs when handling a request
                 that is an instance of this exception class, the associated
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -148,6 +148,10 @@ class Response(object):
         headers (dict): Copy of all headers set for the response,
             sans cookies. Note that a new copy is created and returned each
             time this property is referenced.
+
+        complete (bool): Set to ``True`` from within a middleware method to
+            signal to the framework that request processing should be
+            short-circuited (see also :ref:`Middleware <middleware>`).
     """
 
     __slots__ = (
@@ -164,6 +168,8 @@ class Response(object):
         '__dict__',
     )
 
+    complete = False
+
     # Child classes may override this
     context_type = type('ResponseContext', (dict,), {})
 


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -49,6 +49,10 @@ def process_request(self, req, resp):
         global context
         context['transaction_id'] = 'unique-req-id'
 
+    def process_resource(self, req, resp, resource, params):
+        global context
+        context['resource_transaction_id'] = 'unique-req-id-2'
+
     def process_response(self, req, resp, resource):
         pass
 
@@ -88,6 +92,24 @@ def process_request(self, req, resp):
         req.path = req.path.replace('/base_path', '', 1)
 
 
+class ResponseCacheMiddlware(object):
+
+    PROCESS_REQUEST_CACHED_BODY = {'cached': True}
+    PROCESS_RESOURCE_CACHED_BODY = {'cached': True, 'resource': True}
+
+    def process_request(self, req, resp):
+        if req.path == '/cached':
+            resp.media = self.PROCESS_REQUEST_CACHED_BODY
+            resp.complete = True
+            return
+
+    def process_resource(self, req, resp, resource, params):
+        if req.path == '/cached/resource':
+            resp.media = self.PROCESS_RESOURCE_CACHED_BODY
+            resp.complete = True
+            return
+
+
 class AccessParamsMiddleware(object):
 
     def process_resource(self, req, resp, resource, params):
@@ -710,3 +732,75 @@ def _http_error_handler(error, req, resp, params):
         response = client.simulate_request(path='/', method='POST')
         assert response.status == falcon.HTTP_201
         assert mw.resp.status == response.status
+
+
+class TestShortCircuiting(TestMiddleware):
+    def setup_method(self, method):
+        super(TestShortCircuiting, self).setup_method(method)
+
+    def _make_client(self, independent_middleware=True):
+        mw = [
+            RequestTimeMiddleware(),
+            ResponseCacheMiddlware(),
+            TransactionIdMiddleware(),
+        ]
+        app = falcon.API(middleware=mw, independent_middleware=independent_middleware)
+        app.add_route('/', MiddlewareClassResource())
+        app.add_route('/cached', MiddlewareClassResource())
+        app.add_route('/cached/resource', MiddlewareClassResource())
+
+        return testing.TestClient(app)
+
+    def test_process_request_not_cached(self):
+        response = self._make_client().simulate_get('/')
+        assert response.status == falcon.HTTP_200
+        assert response.json == _EXPECTED_BODY
+        assert 'transaction_id' in context
+        assert 'resource_transaction_id' in context
+        assert 'mid_time' in context
+        assert 'end_time' in context
+
+    @pytest.mark.parametrize('independent_middleware', [True, False])
+    def test_process_request_cached(self, independent_middleware):
+        response = self._make_client(independent_middleware).simulate_get('/cached')
+        assert response.status == falcon.HTTP_200
+        assert response.json == ResponseCacheMiddlware.PROCESS_REQUEST_CACHED_BODY
+
+        # NOTE(kgriffs): Since TransactionIdMiddleware was ordered after
+        # ResponseCacheMiddlware, the response short-circuiting should have
+        # skipped it.
+        assert 'transaction_id' not in context
+        assert 'resource_transaction_id' not in context
+
+        # NOTE(kgriffs): RequestTimeMiddleware only adds this in
+        # process_resource(), which should be skipped when
+        # ResponseCacheMiddlware sets resp.completed = True in
+        # process_request().
+        assert 'mid_time' not in context
+
+        # NOTE(kgriffs): Short-circuiting does not affect process_response()
+        assert 'end_time' in context
+
+    @pytest.mark.parametrize('independent_middleware', [True, False])
+    def test_process_resource_cached(self, independent_middleware):
+        response = self._make_client(independent_middleware).simulate_get('/cached/resource')
+        assert response.status == falcon.HTTP_200
+        assert response.json == ResponseCacheMiddlware.PROCESS_RESOURCE_CACHED_BODY
+
+        # NOTE(kgriffs): This should be present because it is added in
+        # process_request(), but the short-circuit does not occur until
+        # process_resource().
+        assert 'transaction_id' in context
+
+        # NOTE(kgriffs): Since TransactionIdMiddleware was ordered after
+        # ResponseCacheMiddlware, the response short-circuiting should have
+        # skipped it.
+        assert 'resource_transaction_id' not in context
+
+        # NOTE(kgriffs): RequestTimeMiddleware only adds this in
+        # process_resource(), which will not be skipped in this case because
+        # RequestTimeMiddleware is ordered before ResponseCacheMiddlware.
+        assert 'mid_time' in context
+
+        # NOTE(kgriffs): Short-circuiting does not affect process_response()
+        assert 'end_time' in context