Repository: sanic-org/sanic

Issue:
Enforce exception handler uniquness
1. You should not be able to register the same exception more than once, or at least not on the same App/Blueprint.
2. Handlers should only be fetched in relation to the BP or App context of the matched route. This effectively means that some exceptions (`NotFound` could only be registered app level).

_Originally posted by @ahopkins in https://github.com/sanic-org/sanic/issues/2121#issuecomment-827077284_


Discussion:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If this  is incorrect, please respond with an update. Thank you for your contributions.

Hello!

Regarding point 2, there is currently useful usecases to register exceptions like `NotFound` at a BP level. For example, you may have an API blueprint where you want all responses to be returned as JSON; but want them to be returned as HTML everywhere else.

I believe currently it is possible to do this and, if I understand correctly, point 2 would remove the ability to do so.
I guess it could work if you _manually_ raised `NotFound`. But if the route does not exist, there is no way for Sanic to know which blueprint (and therefore which handler) to apply.
What if the blueprint has a URL prefix? I'd expect the blueprint's error handler to try handling any errors within that URL prefix, if no child handles them before. 🤔 
That is not likely something we will introduce since it would require adding default routes to blueprint handlers as catch-all. Which would and could have some bizarre unintended (and difficult to debug) consequences.

Since `NotFound` could be raised manually in legit use cases, maybe we need to identify some (like this and `NoMethod`) that should raise a warning on BP registration.
> What if the blueprint has a URL prefix? I'd expect the blueprint's error handler to try handling any errors within that URL prefix, if no child handles them before. 🤔

This is exactly how I thought it should work.

There can be some use cases out there: Say, if you have a blueprint for a GUI serving and you like to do special stuff with Exceptions here in a (sub)URL.
If that's the case I'd suggest catching the exception with an exception handler and checking if the target path segment is in the current request path.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If this  is incorrect, please respond with an update. Thank you for your contributions.

...
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If this  is incorrect, please respond with an update. Thank you for your contributions.


Resolving patch:
diff --git a/sanic/handlers.py b/sanic/handlers.py
--- a/sanic/handlers.py
+++ b/sanic/handlers.py
@@ -47,6 +47,28 @@ def finalize(cls, *args, **kwargs):
     def _full_lookup(self, exception, route_name: Optional[str] = None):
         return self.lookup(exception, route_name)
 
+    def _add(
+        self,
+        key: Tuple[Type[BaseException], Optional[str]],
+        handler: RouteHandler,
+    ) -> None:
+        if key in self.cached_handlers:
+            exc, name = key
+            if name is None:
+                name = "__ALL_ROUTES__"
+
+            error_logger.warning(
+                f"Duplicate exception handler definition on: route={name} "
+                f"and exception={exc}"
+            )
+            deprecation(
+                "A duplicate exception handler definition was discovered. "
+                "This may cause unintended consequences. A warning has been "
+                "issued now, but it will not be allowed starting in v23.3.",
+                23.3,
+            )
+        self.cached_handlers[key] = handler
+
     def add(self, exception, handler, route_names: Optional[List[str]] = None):
         """
         Add a new exception handler to an already existing handler object.
@@ -62,9 +84,9 @@ def add(self, exception, handler, route_names: Optional[List[str]] = None):
         """
         if route_names:
             for route in route_names:
-                self.cached_handlers[(exception, route)] = handler
+                self._add((exception, route), handler)
         else:
-            self.cached_handlers[(exception, None)] = handler
+            self._add((exception, None), handler)
 
     def lookup(self, exception, route_name: Optional[str] = None):
         """


Test patch:
diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py
--- a/tests/test_exceptions_handler.py
+++ b/tests/test_exceptions_handler.py
@@ -7,7 +7,7 @@
 import pytest
 
 from bs4 import BeautifulSoup
-from pytest import LogCaptureFixture, MonkeyPatch
+from pytest import LogCaptureFixture, MonkeyPatch, WarningsRecorder
 
 from sanic import Sanic, handlers
 from sanic.exceptions import BadRequest, Forbidden, NotFound, ServerError
@@ -266,3 +266,22 @@ async def handler2(request: Request):
 
     _, response = app.test_client.get("/2")
     assert "Error" in response.text
+
+
+def test_warn_on_duplicate(
+    app: Sanic, caplog: LogCaptureFixture, recwarn: WarningsRecorder
+):
+    @app.exception(ServerError)
+    async def exception_handler_1(request, exception):
+        ...
+
+    @app.exception(ServerError)
+    async def exception_handler_2(request, exception):
+        ...
+
+    assert len(caplog.records) == 1
+    assert len(recwarn) == 1
+    assert caplog.records[0].message == (
+        "Duplicate exception handler definition on: route=__ALL_ROUTES__ and "
+        "exception=<class 'sanic.exceptions.ServerError'>"
+    )