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:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=h1) Report
> Merging [#1436](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/14b482a568298ade230a3ff103e8f20cddd3272f?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1436/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1436   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          39      39           
  Lines        2565    2566    +1     
  Branches      396     396           
======================================
+ Hits         2565    2566    +1
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1436/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=footer). Last update [14b482a...4d2a773](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=h1) Report
> Merging [#1436](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/14b482a568298ade230a3ff103e8f20cddd3272f?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1436/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1436   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          39      39           
  Lines        2565    2566    +1     
  Branches      396     396           
======================================
+ Hits         2565    2566    +1
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1436/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=footer). Last update [14b482a...4d2a773](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

codecov-io: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=h1) Report
> Merging [#1436](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/563e8feec576af1e4788f60320edcd94cd407c24?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1436/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1436   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          39      39           
  Lines        2581    2582    +1     
  Branches      403     403           
======================================
+ Hits         2581    2582    +1
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1436/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=footer). Last update [563e8fe...ad335c1](https://codecov.io/gh/falconry/falcon/pull/1436?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index b22c3a1..fe28a82 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -59,6 +59,9 @@ New & Improved
 
 - Added a new ``headers`` property to the ``Response`` class.
 - Removed the ``six`` and ``python-mimeparse`` dependencies.
+- Added a new ``complete`` property to the ``Response`` class. This can be used
+  to short-circuit request processing when the response has been pre-constructed.
+- Removed ``six`` as a dependency.
 - ``Request.context_type`` now defaults to a bare class allowing to set
   attributes on the request context object::
 
diff --git a/docs/api/middleware.rst b/docs/api/middleware.rst
index 723de4b..5296009 100644
--- a/docs/api/middleware.rst
+++ b/docs/api/middleware.rst
@@ -117,13 +117,43 @@ like this::
         mob2.process_response
     mob1.process_response
 
+Short-circuiting
+----------------
+
+A *process_request* middleware method may short-circuit further request
+processing by setting :attr:`~.Response.complete` to ``True``, e.g.::
+
+      ``resp.complete = True``
+
+After the method returns, setting this flag will cause the framework to skip
+any remaining *process_request* and *process_resource* methods, as well as
+the responder method that the request would have been routed to. However, any
+*process_response* middleware methods will still be called.
+
+In a similar manner, setting :attr:`~.Response.complete` to ``True`` from
+within a *process_resource* method will short-circuit further request processing
+at that point.
+
+This feature affords use cases in which the response may be pre-constructed,
+such as in the case of caching.
+
+Exception Handling
+------------------
+
 If one of the *process_request* middleware methods raises an
-error, it will be processed according to the error type. If
+exception, it will be processed according to the exception type. If
 the type matches a registered error handler, that handler will
 be invoked and then the framework will begin to unwind the
 stack, skipping any lower layers. The error handler may itself
-raise an instance of HTTPError, in which case the framework
-will use the latter exception to update the *resp* object.
+raise an instance of :class:`~.HTTPError` or :class:`~.HTTPStatus`, in
+which case the framework will use the latter exception to update the
+*resp* object.
+
+.. Note::
+
+    By default, the framework installs two handlers, one for
+    :class:`~.HTTPError` and one for :class:`~.HTTPStatus`. These can
+    be overridden via :meth:`~.API.add_error_handler`.
 
 Regardless, the framework will continue unwinding the middleware
 stack. For example, if *mob2.process_request* were to raise an
diff --git a/docs/changes/2.0.0.rst b/docs/changes/2.0.0.rst
index 1b1f608..b208bd6 100644
--- a/docs/changes/2.0.0.rst
+++ b/docs/changes/2.0.0.rst
@@ -27,7 +27,7 @@ Breaking Changes
   instead of ``False``.
 - :attr:`~.RequestOptions.auto_parse_qs_csv` now defaults to ``False``
   instead of ``True``.
-- ``independent_middleware`` kwarg on :class:`falcon.API` now defaults to 
+- ``independent_middleware`` kwarg on :class:`falcon.API` now defaults to
   ``True`` instead of ``False``.
 - The deprecated ``stream_len`` property was removed from the
   :class:`~.Response` class. Please use :meth:`~.Response.set_stream` or
@@ -62,6 +62,10 @@ New & Improved
 
 - Added a new :attr:`~.Response.headers` property to the :class:`~.Response` class.
 - Removed the :py:mod:`six` and :py:mod:`python-mimeparse` dependencies.
+- Added a new :attr:`~.Response.complete` property to the :class:`~.Response`
+  class. This can be used to short-circuit request processing when the response
+  has been pre-constructed.
+- Removed :py:mod:`six` from deps.
 - Request :attr:`~.Request.context_type` now defaults to a bare class allowing
   to set attributes on the request context object::
 
diff --git a/falcon/api.py b/falcon/api.py
index 2537fc5..5235fd8 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -210,6 +210,7 @@ class API(object):
         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 @@ class API(object):
                 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 @@ class API(object):
                     # 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 @@ class API(object):
         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 @@ class API(object):
         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
index f1cf5e2..28e37a2 100644
--- 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,), {})
 
diff --git a/tox.ini b/tox.ini
index 24e9cd8..93d6ae9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -53,7 +53,7 @@ whitelist_externals = {[with-coverage]whitelist_externals}
 ; setenv = TOXINIDIR={toxinidir}
 commands = {[with-coverage]commands}
 
-[testenv:py36]
+[testenv:py37]
 deps = {[testenv]deps}
        pytest-randomly
        jsonschema
@@ -88,7 +88,7 @@ deps = {[with-debug-tools]deps}
        jsonschema
 
 [testenv:py3_debug]
-basepython = python3.6
+basepython = python3.7
 deps = {[with-debug-tools]deps}
 
 # --------------------------------------------------------------------


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index b6142e0..38769ea 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -49,6 +49,10 @@ class TransactionIdMiddleware(object):
         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 @@ class RemoveBasePathMiddleware(object):
         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 @@ class TestErrorHandling(TestMiddleware):
         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