Repository: sanic-org/sanic

Issue:
Exception handler decorator in blueprint groups
**Is your feature request related to a problem? Please describe.**
Currently exception handlers can be attached to app and blueprint instances, but it is not possible to do the same with blueprint groups.


**Describe the solution you'd like**
Ideally it would be possible to attach an exception handler to a blueprint group via means of a decorator. If it is not possible to attach a handler to a blueprint group directly, its decorator could simply iterate through each of its blueprint attaching the handler to each of them.


Discussion:

Resolving patch:
diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py
--- a/sanic/blueprint_group.py
+++ b/sanic/blueprint_group.py
@@ -197,6 +197,27 @@ def append(self, value: Blueprint) -> None:
         """
         self._blueprints.append(value)
 
+    def exception(self, *exceptions, **kwargs):
+        """
+        A decorator that can be used to implement a global exception handler
+        for all the Blueprints that belong to this Blueprint Group.
+
+        In case of nested Blueprint Groups, the same handler is applied
+        across each of the Blueprints recursively.
+
+        :param args: List of Python exceptions to be caught by the handler
+        :param kwargs: Additional optional arguments to be passed to the
+            exception handler
+        :return a decorated method to handle global exceptions for any
+            blueprint registered under this group.
+        """
+
+        def register_exception_handler_for_blueprints(fn):
+            for blueprint in self.blueprints:
+                blueprint.exception(*exceptions, **kwargs)(fn)
+
+        return register_exception_handler_for_blueprints
+
     def insert(self, index: int, item: Blueprint) -> None:
         """
         The Abstract class `MutableSequence` leverages this insert method to


Test patch:
diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py
--- a/tests/test_blueprint_group.py
+++ b/tests/test_blueprint_group.py
@@ -3,6 +3,7 @@
 from sanic.app import Sanic
 from sanic.blueprint_group import BlueprintGroup
 from sanic.blueprints import Blueprint
+from sanic.exceptions import Forbidden, InvalidUsage, SanicException, ServerError
 from sanic.request import Request
 from sanic.response import HTTPResponse, text
 
@@ -96,16 +97,28 @@ def test_bp_group(app: Sanic):
     def blueprint_1_default_route(request):
         return text("BP1_OK")
 
+    @blueprint_1.route("/invalid")
+    def blueprint_1_error(request: Request):
+        raise InvalidUsage("Invalid")
+
     @blueprint_2.route("/")
     def blueprint_2_default_route(request):
         return text("BP2_OK")
 
+    @blueprint_2.route("/error")
+    def blueprint_2_error(request: Request):
+        raise ServerError("Error")
+
     blueprint_group_1 = Blueprint.group(
         blueprint_1, blueprint_2, url_prefix="/bp"
     )
 
     blueprint_3 = Blueprint("blueprint_3", url_prefix="/bp3")
 
+    @blueprint_group_1.exception(InvalidUsage)
+    def handle_group_exception(request, exception):
+        return text("BP1_ERR_OK")
+
     @blueprint_group_1.middleware("request")
     def blueprint_group_1_middleware(request):
         global MIDDLEWARE_INVOKE_COUNTER
@@ -130,10 +143,18 @@ def blueprint_group_1_convenience_2(request):
     def blueprint_3_default_route(request):
         return text("BP3_OK")
 
+    @blueprint_3.route("/forbidden")
+    def blueprint_3_forbidden(request: Request):
+        raise Forbidden("Forbidden")
+
     blueprint_group_2 = Blueprint.group(
         blueprint_group_1, blueprint_3, url_prefix="/api"
     )
 
+    @blueprint_group_2.exception(SanicException)
+    def handle_non_handled_exception(request, exception):
+        return text("BP2_ERR_OK")
+
     @blueprint_group_2.middleware("response")
     def blueprint_group_2_middleware(request, response):
         global MIDDLEWARE_INVOKE_COUNTER
@@ -161,14 +182,23 @@ def app_default_route(request):
     _, response = app.test_client.get("/api/bp/bp1")
     assert response.text == "BP1_OK"
 
+    _, response = app.test_client.get("/api/bp/bp1/invalid")
+    assert response.text == "BP1_ERR_OK"
+
     _, response = app.test_client.get("/api/bp/bp2")
     assert response.text == "BP2_OK"
 
+    _, response = app.test_client.get("/api/bp/bp2/error")
+    assert response.text == "BP2_ERR_OK"
+
     _, response = app.test_client.get("/api/bp3")
     assert response.text == "BP3_OK"
 
-    assert MIDDLEWARE_INVOKE_COUNTER["response"] == 9
-    assert MIDDLEWARE_INVOKE_COUNTER["request"] == 8
+    _, response = app.test_client.get("/api/bp3/forbidden")
+    assert response.text == "BP2_ERR_OK"
+
+    assert MIDDLEWARE_INVOKE_COUNTER["response"] == 18
+    assert MIDDLEWARE_INVOKE_COUNTER["request"] == 16
 
 
 def test_bp_group_list_operations(app: Sanic):