Repository: sanic-org/sanic

Issue:
Unexpected behavior using a catch-all Blueprint exception handler
**Describe the bug**
Using a catch-all exception handler in a Blueprint might lead to unexpected behavior. For example:

```python
from sanic import Sanic, Blueprint, response
from sanic.exceptions import NotFound

error_handlers = Blueprint(__name__)


@error_handlers.exception(NotFound)
def not_found(request, exception):
    return response.text("Not found", status=404)


@error_handlers.exception(Exception)
def unhandled_exceptions(request, exception):
    return response.text("Unhandled exception", status=500)


app = Sanic("My Hello, world app")
app.blueprint(error_handlers)


@app.route("/")
async def test(request):
    return json({"hello": "world"})


if __name__ == '__main__':
    app.run(debug=True)
```
One might think that the `not_found` would handle all 404's, but that's not always the case, sometimes the `unhandled_exceptions` handler is being used instead, restarting the application will give "random" results.

From what I can see the underlying problem is this line: https://github.com/sanic-org/sanic/blob/main/sanic/handlers.py#L67.

Since all exceptions derive from `Exception` they will return `True` here when compared to the `unhandled_exceptions` exception `Exception`. So it's basically the order of the `self.handlers` that will determine which error handler to be used (if there are multiple handlers registered for the same derived exception) since it returns early on the first match.

Also, the reason for "random" results between restarts seems to be that a `set` (undefined order) is used as the data structure for storing the registered exception handlers: https://github.com/sanic-org/sanic/blob/main/sanic/mixins/exceptions.py#L8 when using a Blueprint.

Previously in versions <21.x this used to be a `list` and the problem above could be "circumvented" by registering the catch-all exception handler last. This is also how the `app.error_handler` seems to be working and the workaround still works for normal application routes.

**Expected behavior**
The explicitly registered exception handler should primarily be used even thou a catch-all handler is registered, the order when the handler was registered shouldn't matter. I would also expect the same behavior for both Blueprint and normal application routes.

**Environment**
 - Version: 21.3.2



Discussion:
Looks like the simplest solution would be to precache the handler:

```python
self.cached_handlers[exception] = handler
```

At first glance, I am not entirely sure why this was not done.

---

Digging deeper, it looks to me like a better solution would be either, at run time loop thru `mro` to find the nearest ancestor (and cache that).

Resolving patch:
diff --git a/sanic/handlers.py b/sanic/handlers.py
--- a/sanic/handlers.py
+++ b/sanic/handlers.py
@@ -25,7 +25,6 @@ class ErrorHandler:
 
     handlers = None
     cached_handlers = None
-    _missing = object()
 
     def __init__(self):
         self.handlers = []
@@ -45,7 +44,9 @@ def add(self, exception, handler):
 
         :return: None
         """
+        # self.handlers to be deprecated and removed in version 21.12
         self.handlers.append((exception, handler))
+        self.cached_handlers[exception] = handler
 
     def lookup(self, exception):
         """
@@ -61,14 +62,19 @@ def lookup(self, exception):
 
         :return: Registered function if found ``None`` otherwise
         """
-        handler = self.cached_handlers.get(type(exception), self._missing)
-        if handler is self._missing:
-            for exception_class, handler in self.handlers:
-                if isinstance(exception, exception_class):
-                    self.cached_handlers[type(exception)] = handler
-                    return handler
-            self.cached_handlers[type(exception)] = None
-            handler = None
+        exception_class = type(exception)
+        if exception_class in self.cached_handlers:
+            return self.cached_handlers[exception_class]
+
+        for ancestor in type.mro(exception_class):
+            if ancestor in self.cached_handlers:
+                handler = self.cached_handlers[ancestor]
+                self.cached_handlers[exception_class] = handler
+                return handler
+            if ancestor is BaseException:
+                break
+        self.cached_handlers[exception_class] = None
+        handler = None
         return handler
 
     def response(self, request, exception):


Test patch: