Repository: sanic-org/sanic
Pull request: Account for BP with exception handler but no routes
See [discussion](https://github.com/sanic-org/sanic/issues/2202#issuecomment-927693978). If a BP has an exception handler and no routes assigned, it could inadvertently attach the handler to the global scope. This would be an unintended and difficult to debug consequence.

Fixes #2242 

Problem statement:
Blueprint exceptions are sometimes handled by other blueprint's exception handlers
**Describe the bug**
Exceptions thrown from within one blueprint are sometimes handled by the exception handler of another, unrelated blueprint instead of by its own. I have not had time to check whether this happens all the time or only if specific conditions are met, but I have attached a code snippet with which the issue can be reproduced.


**Code snippet**
```py
from sanic import Blueprint, HTTPResponse, Request, Sanic
from sanic.exceptions import SanicException
from sanic.response import text


class BaseException(SanicException):
    code: int
    description: str
    status_code: int
    headers: dict = {}


class ExceptionA(BaseException):
    error: str


class ExceptionB(BaseException):
    code = 0
    status_code = 400


class ExceptionBA(ExceptionB, ExceptionA):
    error = "foo"
    description = "Bar!"


app = Sanic("my_app")
bp1 = Blueprint("auth", url_prefix="/auth")
bp2 = Blueprint("token")


@bp1.route("/network", version=1)
async def error_bp1(_: Request):
    raise ExceptionBA()


@bp2.route("/token")
async def hello_bp2(_: Request):
    return text("P3rry7hePl4typu5")


bpg1 = Blueprint.group(bp1, version_prefix="/api/v")
bpg2 = Blueprint.group(bp2, url_prefix="/api/oauth2")


for bp in bpg1.blueprints:
    @bp.exception(BaseException)
    async def _(_, ex: BaseException) -> HTTPResponse:
        return text("BPG1_BaseException")

    @bp.exception(Exception)
    async def _(request: Request, ex: Exception) -> HTTPResponse:
        return text("BPG1_Exception")


for bp in bpg2.blueprints:
    @bp.exception(ExceptionA)
    async def _(_, ex: ExceptionA):
        return text("BPG2_ExceptionA")

    @bp.exception(Exception)
    async def _(request: Request, ex: Exception):
        return text("BPG2_Exception")


bpg_all = Blueprint.group(bpg1, bpg2)
app.blueprint(bpg_all)


if __name__ == "__main__":
    app.run(debug=True)
```


**Expected behavior**
When accessing `/api/v1/auth/network`, an exception is raised, which is captured by the `bpg1` exception handler; rendering a final response which displays the string `BPG1_BaseException`.


**Actual behavior**
When accessing `/api/v1/auth/network`, an exception is raised, which is (somehow) captured by the `bpg2` exception handler; rendering a final response which displays the string `BPG2_ExceptionA`.


**Environment (please complete the following information):**
 - OS: Windows 10 Pro; 21H1; Compilation 19043.1165.
 - Versions: Sanic 21.6.2; Routing 0.7.1

Hints from issue discussion:

Commit message: Account for BP with exception handler but no routes

Patch to review:
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index 31809acf74..57426a865a 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -337,10 +337,11 @@ def register(self, app, options):
                 middleware.append(app._apply_middleware(future, route_names))
 
         # Exceptions
-        for future in self._future_exceptions:
-            exception_handlers.append(
-                app._apply_exception_handler(future, route_names)
-            )
+        if route_names:
+            for future in self._future_exceptions:
+                exception_handlers.append(
+                    app._apply_exception_handler(future, route_names)
+                )
 
         # Event listeners
         for listener in self._future_listeners:
diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py
index fec7b50a41..0ffff197cf 100644
--- a/tests/test_blueprints.py
+++ b/tests/test_blueprints.py
@@ -83,7 +83,6 @@ def handler(request):
             return text("OK")
 
     else:
-        print(func)
         raise Exception(f"{func} is not callable")
 
     app.blueprint(bp)
@@ -477,6 +476,58 @@ def handler_exception(request, exception):
     assert response.status == 200
 
 
+def test_bp_exception_handler_applied(app):
+    class Error(Exception):
+        pass
+
+    handled = Blueprint("handled")
+    nothandled = Blueprint("nothandled")
+
+    @handled.exception(Error)
+    def handle_error(req, e):
+        return text("handled {}".format(e))
+
+    @handled.route("/ok")
+    def ok(request):
+        raise Error("uh oh")
+
+    @nothandled.route("/notok")
+    def notok(request):
+        raise Error("uh oh")
+
+    app.blueprint(handled)
+    app.blueprint(nothandled)
+
+    _, response = app.test_client.get("/ok")
+    assert response.status == 200
+    assert response.text == "handled uh oh"
+
+    _, response = app.test_client.get("/notok")
+    assert response.status == 500
+
+
+def test_bp_exception_handler_not_applied(app):
+    class Error(Exception):
+        pass
+
+    handled = Blueprint("handled")
+    nothandled = Blueprint("nothandled")
+
+    @handled.exception(Error)
+    def handle_error(req, e):
+        return text("handled {}".format(e))
+
+    @nothandled.route("/notok")
+    def notok(request):
+        raise Error("uh oh")
+
+    app.blueprint(handled)
+    app.blueprint(nothandled)
+
+    _, response = app.test_client.get("/notok")
+    assert response.status == 500
+
+
 def test_bp_listeners(app):
     app.route("/")(lambda x: x)
     blueprint = Blueprint("test_middleware")


Code review comments:
diff_hunk: @@ -337,10 +337,11 @@ def register(self, app, options):
                 middleware.append(app._apply_middleware(future, route_names))
 
         # Exceptions
-        for future in self._future_exceptions:
-            exception_handlers.append(
-                app._apply_exception_handler(future, route_names)
-            )
+        if route_names:
path: sanic/blueprints.py
text: @user1:
Just being a bit pedantic here, but you should be able to remove this `if` statement only and it should still work (since the code is now indented inside another `if route_names` check :)

@author:
You're right. I was thinking it would be cleaner this way since each primitive we are applying is separate.