Repository: falconry/falcon
Pull request #1647 (2020-01-22 20:00:18)

feat(responders): Raise a specialized subclass of HTTPNotFound
When no route matches a request, raise a subclass of HTTPNotFound so that
a custom error handler can distinguish that specific case if desired.

(This was inspired by a recent chat with a user in the community.)

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

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

```diff
@@          Coverage Diff           @@
##           master   #1647   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          45      45           
  Lines        3107    3109    +2     
  Branches      479     479           
======================================
+ Hits         3107    3109    +2
```


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

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1647?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/1647?src=pr&el=footer). Last update [cd1fcfc...191dc64](https://codecov.io/gh/falconry/falcon/pull/1647?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: Good ideas, I'll work on those.
kgriffs: > * We should adapt tests to verify the new behaviour, i.e. that the specialized subclass is raised, ideally in a scenario where the responder, if routed, would raise a plain `HTTPNotFound`.
> * `docs/api/routing.rst` needs to be revised
> * `docs/user/faq.rst` needs to be revised (`How can I write a custom handler for 404 and 500 pages in falcon?`)

@vytas7  I've attempted to address these points. Let me know what you think!

Patch:
diff --git a/docs/_newsfragments/default-404-responder-specialized-error.feature.rst b/docs/_newsfragments/default-404-responder-specialized-error.feature.rst
new file mode 100644
index 0000000..06a84b9
--- /dev/null
+++ b/docs/_newsfragments/default-404-responder-specialized-error.feature.rst
@@ -0,0 +1,4 @@
+When no route matches a request, the framework will now raise a
+specialized subclass of :class:`~.falcon.HTTPNotFound`
+(:class:`~.falcon.HTTPRouteNotFound`) so that
+a custom error handler can distinguish that specific case if desired.
diff --git a/docs/api/errors.rst b/docs/api/errors.rst
index ec63c5b..36edc5c 100644
--- a/docs/api/errors.rst
+++ b/docs/api/errors.rst
@@ -87,20 +87,107 @@ Mixins
 Predefined Errors
 -----------------
 
-.. automodule:: falcon
-    :noindex:
-    :members: HTTPBadRequest,
-        HTTPInvalidHeader, HTTPMissingHeader,
-        HTTPInvalidParam, HTTPMissingParam,
-        HTTPUnauthorized, HTTPForbidden, HTTPNotFound, HTTPMethodNotAllowed,
-        HTTPNotAcceptable, HTTPConflict, HTTPGone, HTTPLengthRequired,
-        HTTPPreconditionFailed, HTTPPayloadTooLarge, HTTPUriTooLong,
-        HTTPUnsupportedMediaType, HTTPRangeNotSatisfiable,
-        HTTPUnprocessableEntity, HTTPLocked, HTTPFailedDependency,
-        HTTPPreconditionRequired, HTTPTooManyRequests,
-        HTTPRequestHeaderFieldsTooLarge,
-        HTTPUnavailableForLegalReasons,
-        HTTPInternalServerError, HTTPNotImplemented, HTTPBadGateway,
-        HTTPServiceUnavailable, HTTPGatewayTimeout, HTTPVersionNotSupported,
-        HTTPInsufficientStorage, HTTPLoopDetected,
-        HTTPNetworkAuthenticationRequired
+.. autoclass:: falcon.HTTPBadRequest
+    :members:
+
+.. autoclass:: falcon.HTTPInvalidHeader
+    :members:
+
+.. autoclass:: falcon.HTTPMissingHeader
+    :members:
+
+.. autoclass:: falcon.HTTPInvalidParam
+    :members:
+
+.. autoclass:: falcon.HTTPMissingParam
+    :members:
+
+.. autoclass:: falcon.HTTPUnauthorized
+    :members:
+
+.. autoclass:: falcon.HTTPForbidden
+    :members:
+
+.. autoclass:: falcon.HTTPNotFound
+    :members:
+
+.. autoclass:: falcon.HTTPRouteNotFound
+    :members:
+
+.. autoclass:: falcon.HTTPMethodNotAllowed
+    :members:
+
+.. autoclass:: falcon.HTTPNotAcceptable
+    :members:
+
+.. autoclass:: falcon.HTTPConflict
+    :members:
+
+.. autoclass:: falcon.HTTPGone
+    :members:
+
+.. autoclass:: falcon.HTTPLengthRequired
+    :members:
+
+.. autoclass:: falcon.HTTPPreconditionFailed
+    :members:
+
+.. autoclass:: falcon.HTTPPayloadTooLarge
+    :members:
+
+.. autoclass:: falcon.HTTPUriTooLong
+    :members:
+
+.. autoclass:: falcon.HTTPUnsupportedMediaType
+    :members:
+
+.. autoclass:: falcon.HTTPRangeNotSatisfiable
+    :members:
+
+.. autoclass:: falcon.HTTPUnprocessableEntity
+    :members:
+
+.. autoclass:: falcon.HTTPLocked
+    :members:
+
+.. autoclass:: falcon.HTTPFailedDependency
+    :members:
+
+.. autoclass:: falcon.HTTPPreconditionRequired
+    :members:
+
+.. autoclass:: falcon.HTTPTooManyRequests
+    :members:
+
+.. autoclass:: falcon.HTTPRequestHeaderFieldsTooLarge
+    :members:
+
+.. autoclass:: falcon.HTTPUnavailableForLegalReasons
+    :members:
+
+.. autoclass:: falcon.HTTPInternalServerError
+    :members:
+
+.. autoclass:: falcon.HTTPNotImplemented
+    :members:
+
+.. autoclass:: falcon.HTTPBadGateway
+    :members:
+
+.. autoclass:: falcon.HTTPServiceUnavailable
+    :members:
+
+.. autoclass:: falcon.HTTPGatewayTimeout
+    :members:
+
+.. autoclass:: falcon.HTTPVersionNotSupported
+    :members:
+
+.. autoclass:: falcon.HTTPInsufficientStorage
+    :members:
+
+.. autoclass:: falcon.HTTPLoopDetected
+    :members:
+
+.. autoclass:: falcon.HTTPNetworkAuthenticationRequired
+    :members:
diff --git a/docs/api/routing.rst b/docs/api/routing.rst
index acabc5f..7ab3802 100644
--- a/docs/api/routing.rst
+++ b/docs/api/routing.rst
@@ -8,11 +8,6 @@ templates. If the path requested by the client matches the template for
 a given route, the request is then passed on to the associated resource
 for processing.
 
-If no route matches the request, control then passes to a default
-responder that simply raises an instance of :class:`~.HTTPNotFound`.
-Normally this will result in sending a 404 response back to the
-client.
-
 Here's a quick example to show how all the pieces fit together:
 
 .. code:: python
@@ -45,6 +40,17 @@ Here's a quick example to show how all the pieces fit together:
     images = ImagesResource()
     app.add_route('/images', images)
 
+If no route matches the request, control then passes to a default
+responder that simply raises an instance of
+:class:`~.HTTPRouteNotFound`. By default, this error will be
+rendered as a 404 response, but this behavior can be modified by
+adding a custom error handler (see also
+:ref:`this FAQ topic <faq_override_404_500_handlers>`).
+
+On the other hand, if a route is matched but the resource does not
+implement a responder for the requested HTTP method, the framework
+invokes a default responder that raises an instance of
+:class:`~.HTTPMethodNotAllowed`.
 
 Default Router
 --------------
diff --git a/docs/user/faq.rst b/docs/user/faq.rst
index f2b4949..0d59ee5 100644
--- a/docs/user/faq.rst
+++ b/docs/user/faq.rst
@@ -436,22 +436,23 @@ around:
     def on_post(self, req, resp):
         pass
 
+.. _faq_override_404_500_handlers:
 
 How can I write a custom handler for 404 and 500 pages in falcon?
 ------------------------------------------------------------------
 When a route can not be found for an incoming request, Falcon uses a default
-responder that simply raises an instance of :attr:`falcon.HTTPNotFound`. You
-can use :meth:`falcon.App.add_error_handler` to register a custom error handler
-for this exception type. Alternatively, you may be able to configure your web
-server to transform the response for you (e.g., using Nginx's ``error_page``
-directive).
+responder that simply raises an instance of :class:`~.HTTPRouteNotFound`, which
+the framework will in turn render as a 404 response. You can use
+:meth:`falcon.App.add_error_handler` to override the default handler for this
+exception type (or for its parent type, :class:`~.HTTPNotFound`).
+Alternatively, you may be able to configure your web server to transform the
+response for you (e.g., using nginx's ``error_page`` directive).
 
 By default, non-system-exiting exceptions that do not inherit from
 :class:`~.HTTPError` or :class:`~.HTTPStatus` are handled by Falcon with a
 plain HTTP 500 error. To provide your own 500 logic, you can add a custom error
-handler for Python's base :class:`Exception` type, though be aware that doing
-so will also override the default handlers for :class:`~.HTTPError` and
-:class:`~.HTTPStatus`.
+handler for Python's base :class:`Exception` type. This will not affect the
+default handlers for :class:`~.HTTPError` and :class:`~.HTTPStatus`.
 
 See :ref:`errors` and the :meth:`falcon.API.add_error_handler` docs for more
 details.
diff --git a/falcon/app.py b/falcon/app.py
index c6d1a62..c971509 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -422,9 +422,19 @@ class App:
         template for a given route, the request is then passed on to the
         associated resource for processing.
 
-        If no route matches the request, control then passes to a
-        default responder that simply raises an instance of
-        :class:`~.HTTPNotFound`.
+        Note:
+
+            If no route matches the request, control then passes to a default
+            responder that simply raises an instance of
+            :class:`~.HTTPRouteNotFound`. By default, this error will be
+            rendered as a 404 response, but this behavior can be modified by
+            adding a custom error handler (see also
+            :ref:`this FAQ topic <faq_override_404_500_handlers>`).
+
+            On the other hand, if a route is matched but the resource does not
+            implement a responder for the requested HTTP method, the framework
+            invokes a default responder that raises an instance of
+            :class:`~.HTTPMethodNotAllowed`.
 
         This method delegates to the configured router's ``add_route()``
         method. To override the default behavior, pass a custom router
diff --git a/falcon/errors.py b/falcon/errors.py
index 59609fa..b14e6f9 100644
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -282,6 +282,50 @@ class HTTPNotFound(OptionalRepresentation, HTTPError):
         super(HTTPNotFound, self).__init__(status.HTTP_404, headers=headers, **kwargs)
 
 
+class HTTPRouteNotFound(HTTPNotFound):
+    """404 Not Found.
+
+    The request did not match any routes configured for the application.
+
+    This subclass of :class:`~.HTTPNotFound` is raised by the framework to
+    provide a default 404 response when no route matches the request. This
+    behavior can be customized by registering a custom error handler for
+    :class:`~.HTTPRouteNotFound`.
+
+    Keyword Args:
+        title (str): Human-friendly error title. If not provided, and
+            `description` is also not provided, no body will be included
+            in the response.
+        description (str): Human-friendly description of the error, along with
+            a helpful suggestion or two (default ``None``).
+        headers (dict or list): A ``dict`` of header names and values
+            to set, or a ``list`` of (*name*, *value*) tuples. Both *name* and
+            *value* must be of type ``str`` or ``StringType``, and only
+            character values 0x00 through 0xFF may be used on platforms that
+            use wide characters.
+
+            Note:
+                The Content-Type header, if present, will be overridden. If
+                you wish to return custom error messages, you can create
+                your own HTTP error class, and install an error handler
+                to convert it into an appropriate HTTP response for the
+                client
+
+            Note:
+                Falcon can process a list of ``tuple`` slightly faster
+                than a ``dict``.
+        href (str): A URL someone can visit to find out more information
+            (default ``None``). Unicode characters are percent-encoded.
+        href_text (str): If href is given, use this as the friendly
+            title/description for the link (default 'API documentation
+            for this error').
+        code (int): An internal code that customers can reference in their
+            support request or to help them when searching for knowledge
+            base articles related to this error (default ``None``).
+    """
+    pass
+
+
 class HTTPMethodNotAllowed(OptionalRepresentation, HTTPError):
     """405 Method Not Allowed.
 
diff --git a/falcon/responders.py b/falcon/responders.py
index 5258837..516bf0f 100644
--- a/falcon/responders.py
+++ b/falcon/responders.py
@@ -16,18 +16,18 @@
 
 from falcon.errors import HTTPBadRequest
 from falcon.errors import HTTPMethodNotAllowed
-from falcon.errors import HTTPNotFound
+from falcon.errors import HTTPRouteNotFound
 from falcon.status_codes import HTTP_200
 
 
 def path_not_found(req, resp, **kwargs):
-    """Raise 404 HTTPNotFound error"""
-    raise HTTPNotFound()
+    """Raise 404 HTTPRouteNotFound error"""
+    raise HTTPRouteNotFound()
 
 
 async def path_not_found_async(req, resp, **kwargs):
-    """Raise 404 HTTPNotFound error"""
-    raise HTTPNotFound()
+    """Raise 404 HTTPRouteNotFound error"""
+    raise HTTPRouteNotFound()
 
 
 def bad_request(req, resp, **kwargs):


Test patch:
diff --git a/tests/test_error_handlers.py b/tests/test_error_handlers.py
index ab83995..385a523 100644
--- a/tests/test_error_handlers.py
+++ b/tests/test_error_handlers.py
@@ -236,3 +236,26 @@ class TestErrorHandler:
         with disable_asgi_non_coroutine_wrapping():
             with pytest.raises(ValueError):
                 app.add_error_handler(Exception, capture_error)
+
+    def test_catch_http_no_route_error(self, asgi):
+        class Resource:
+            def on_get(self, req, resp):
+                raise falcon.HTTPNotFound()
+
+        def capture_error(req, resp, ex, params):
+            resp.body = ex.__class__.__name__
+            raise ex
+
+        app = create_app(asgi)
+        app.add_route('/', Resource())
+        app.add_error_handler(falcon.HTTPError, capture_error)
+
+        client = testing.TestClient(app)
+
+        result = client.simulate_get('/')
+        assert result.status_code == 404
+        assert result.text == 'HTTPNotFound'
+
+        result = client.simulate_get('/404')
+        assert result.status_code == 404
+        assert result.text == 'HTTPRouteNotFound'