Repository: encode/starlette

Issue:
middleware causes exceptions to not be raised/handled silently (back again)
Regression of #1976 #1977 #1609 #1940 

This time I have noticed that changing `MyExc(Exception)` to `MyExc(BaseException)` means the error does get sent to stdout (if that helps) I tried to have a dig but I am not too sure where that catch exception is that is catching the Exception (and silently passing) and not the BaseException 

starlette==0.37.2
fastapi==0.111.0

```python
import uvicorn
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()


class MyExc(Exception):  # change to BaseException and then both exceptions are sent to stdout
    ...


@app.get("/info")
def info():
    # raises Exception as expected, the traceback is seen in console
    raise MyExc


private_api = FastAPI()


@private_api.get("/info")
def info():
    # exception is handled silently, no traceback is seen in console
    raise MyExc


app.mount("/private", private_api)


class Middleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        return await call_next(request)


app.add_middleware(Middleware)  # when this is removed, the exceptions are raised for all routes

if __name__ == "__main__":
    uvicorn.run(app, port=8000)
```

<!-- POLAR PLEDGE BADGE START -->
> [!IMPORTANT]
> - We're using [Polar.sh](https://polar.sh/encode) so you can upvote and help fund this issue.
> - We receive the funding once the issue is completed & confirmed by you.
> - Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/encode/starlette/issues/2625">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/encode/starlette/issues/2625/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/encode/starlette/issues/2625/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I have the same issue
@daniilmastrangeli a fix I have found for now is to add_exception_handler which catches the exception and if re-raised will display the trace to stdout as expected

```python
import uvicorn
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()


class MyExc(Exception):  
    ...


@app.get("/info")
def info():
    raise MyExc


private_api = FastAPI()


@private_api.get("/info")
def info():
    raise MyExc


app.mount("/private", private_api)


class Middleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        return await call_next(request)


def re_raise_exception(_: Request, exc: Exception):
    raise exc from exc


app.add_middleware(Middleware) 
app.add_exception_handler(Exception, re_raise_exception)  # raises the exception in the handler which results in getting handled by a different context

if __name__ == "__main__":
    uvicorn.run(app, port=8000)
```
@fraser-langton ty, it's worked
The root cause is here: https://github.com/encode/starlette/pull/2194#issuecomment-2235897188.

For more references, see also: https://github.com/fastapi/fastapi/discussions/8577#discussioncomment-8914357
This regression happened on 0.29.0.
I've started some work on https://github.com/encode/starlette/pull/2696. 
Is there a way to work around this for background tasks as well? The workaround functions for me when the exception occurs in my endpoint's code. But if the endpoint starts a background task and that task throws an exception, it gets lost.
@mike-schiller I haven't got one, didn't need it for background tasks personally 
You can use https://github.com/adriangb/asgi-background which isn't impacted by this.
Thanks @adriangb. I'll check that out. For the moment I implemented a decorator that I apply to all my background tasks to wrap them in try/except with logging.
Can someone test https://github.com/encode/starlette/pull/2812, please?



Patch:
diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py
index f146984b3..2a59337e5 100644
--- a/starlette/middleware/base.py
+++ b/starlette/middleware/base.py
@@ -103,10 +103,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
         request = _CachedRequest(scope, receive)
         wrapped_receive = request.wrapped_receive
         response_sent = anyio.Event()
+        app_exc: Exception | None = None
 
         async def call_next(request: Request) -> Response:
-            app_exc: Exception | None = None
-
             async def receive_or_disconnect() -> Message:
                 if response_sent.is_set():
                     return {"type": "http.disconnect"}
@@ -165,9 +164,6 @@ async def body_stream() -> typing.AsyncGenerator[bytes, None]:
                     if not message.get("more_body", False):
                         break
 
-                if app_exc is not None:
-                    raise app_exc
-
             response = _StreamingResponse(status_code=message["status"], content=body_stream(), info=info)
             response.raw_headers = message["headers"]
             return response
@@ -181,6 +177,9 @@ async def body_stream() -> typing.AsyncGenerator[bytes, None]:
                 response_sent.set()
                 recv_stream.close()
 
+        if app_exc is not None:
+            raise app_exc
+
     async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
         raise NotImplementedError()  # pragma: no cover
 
diff --git a/starlette/responses.py b/starlette/responses.py
index 5964df678..81e89faef 100644
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -18,6 +18,7 @@
 import anyio
 import anyio.to_thread
 
+from starlette._utils import collapse_excgroups
 from starlette.background import BackgroundTask
 from starlette.concurrency import iterate_in_threadpool
 from starlette.datastructures import URL, Headers, MutableHeaders
@@ -258,14 +259,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
             except OSError:
                 raise ClientDisconnect()
         else:
-            async with anyio.create_task_group() as task_group:
+            with collapse_excgroups():
+                async with anyio.create_task_group() as task_group:
 
-                async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
-                    await func()
-                    task_group.cancel_scope.cancel()
+                    async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
+                        await func()
+                        task_group.cancel_scope.cancel()
 
-                task_group.start_soon(wrap, partial(self.stream_response, send))
-                await wrap(partial(self.listen_for_disconnect, receive))
+                    task_group.start_soon(wrap, partial(self.stream_response, send))
+                    await wrap(partial(self.listen_for_disconnect, receive))
 
         if self.background is not None:
             await self.background()


Test patch:
diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py
index 7232cfd18..e4e82077f 100644
--- a/tests/middleware/test_base.py
+++ b/tests/middleware/test_base.py
@@ -297,6 +297,29 @@ async def send(message: Message) -> None:
     assert background_task_run.is_set()
 
 
+def test_run_background_tasks_raise_exceptions(test_client_factory: TestClientFactory) -> None:
+    # test for https://github.com/encode/starlette/issues/2625
+
+    async def sleep_and_set() -> None:
+        await anyio.sleep(0.1)
+        raise ValueError("TEST")
+
+    async def endpoint_with_background_task(_: Request) -> PlainTextResponse:
+        return PlainTextResponse(background=BackgroundTask(sleep_and_set))
+
+    async def passthrough(request: Request, call_next: RequestResponseEndpoint) -> Response:
+        return await call_next(request)
+
+    app = Starlette(
+        middleware=[Middleware(BaseHTTPMiddleware, dispatch=passthrough)],
+        routes=[Route("/", endpoint_with_background_task)],
+    )
+
+    client = test_client_factory(app)
+    with pytest.raises(ValueError, match="TEST"):
+        client.get("/")
+
+
 @pytest.mark.anyio
 async def test_do_not_block_on_background_tasks() -> None:
     response_complete = anyio.Event()