Repository: encode/starlette

Issue:
Raising Exceptions in sub-applications routes
### Checklist

- [X] The bug is reproducible against the latest release or `master`.
- [X] There are no similar issues or pull requests to fix it yet.

### Describe the bug

Let's start with this PR: #1262

It's about preventing raise `anyio.ExceptionGroup` in views under a `BaseHTTPMiddleware`. PR resolve that problem with nonlocal variable that stores our exception. But in the case of sub-applications, it does not work. 

As I can see (fyi I am not good at asyncio), in the case below, we reach and read a response before we raise an exception and store it to our nonlocal variable:

fragment of `BaseHTTPMiddleware.__call__`
```python
async def call_next(request: Request) -> Response:
    app_exc: typing.Optional[Exception] = None
    send_stream, recv_stream = anyio.create_memory_object_stream()

    async def coro() -> None:
        nonlocal app_exc

        async with send_stream:
            try:
                task = await self.app(scope, request.receive, send_stream.send)
            except Exception as exc:
                app_exc = exc

    task_group.start_soon(coro)

    try:
        message = await recv_stream.receive()
    except anyio.EndOfStream:
        if app_exc is not None:
            raise app_exc
        raise RuntimeError("No response returned.")
    
    ...
    response = StreamingResponse(
        status_code=message["status"], content=body_stream()
    )
    response.raw_headers = message["headers"]
    return response
```

in this moment: `except anyio.EndOfStream:` exception still no raised.

### Steps to reproduce the bug

```python
import httpx
import pytest
from fastapi import FastAPI, APIRouter
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route


class SomeError(Exception):
    pass


class SomeMiddleware(BaseHTTPMiddleware):
    async def dispatch(
        self, request: Request, call_next: RequestResponseEndpoint
    ) -> Response:
        return await call_next(request)

# Drop (or use not BaseHTTPMiddleware based) middleware and test works fine
app = FastAPI(middleware=[Middleware(SomeMiddleware), ])


async def simple_route(request: Request):
    raise SomeError


another_router = APIRouter(
    routes=[Route('/simple-route/', simple_route, methods=['GET'])]
)
sub_app = FastAPI()
sub_app.include_router(another_router)
app.router.mount(f'/api', sub_app)


@pytest.mark.asyncio
async def test_simple_route():
    async with httpx.AsyncClient(app=app) as client:
        with pytest.raises(SomeError):
            await client.get("http://testserver/api/simple-route/")

```

### Expected behavior

An exception was raised and caught by pytest exception

### Actual behavior

An exception wasn't raised

### Debugging material

_No response_

### Environment

macOS Monterey 12.0.1, starlette 0.17.1, Python 3.9.9


### Additional context

_No response_

Discussion:
o-fedorov: > Alternative to #1452
> 
> Would you mind adding this test?
> 
> ```python
> def test_exception_on_mounted_apps(test_client_factory):
>     sub_app = Starlette(routes=[Route("/", exc)])
>     app.mount("/sub", sub_app)
> 
>     client = test_client_factory(app)
>     with pytest.raises(Exception) as ctx:
>         client.get("/sub/")
>     assert str(ctx.value) == "Exc"
> ```

The test is added
Kludex: I don't think this work if the `Response` object from the middleware is ignored, as below:

```python
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class CustomHeaderMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        await call_next(request)
        return Response("Do you see an exception now?")

middleware = [
    Middleware(CustomHeaderMiddleware)
]

app = Starlette(routes=routes, middleware=middleware)
```
Can you confirm? Using the same `StreamingResponse` endpoint, you created on your test with this middleware.

#1452 should be able to solve this.
o-fedorov: Sorry, both this approach and #1452 seems to not solving the issue  if the Response object from the middleware is ignored
Kludex: More context about this PR can be found on #1452 and #1433.

The issue here is that we check if the endpoint (or app) raised an exception only if `http.response.start` is not sent, but `StreamingResponse` (and mounted apps) pass that conditional. 
Kludex: Thanks for the PR @o-fedorov ! :)

Patch:
diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py
index 423f407..bfb4a54 100644
--- a/starlette/middleware/base.py
+++ b/starlette/middleware/base.py
@@ -52,6 +52,9 @@ class BaseHTTPMiddleware:
                         assert message["type"] == "http.response.body"
                         yield message.get("body", b"")
 
+                if app_exc is not None:
+                    raise app_exc
+
             response = StreamingResponse(
                 status_code=message["status"], content=body_stream()
             )


Test patch:
diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py
index c6bfd49..32468dc 100644
--- a/tests/middleware/test_base.py
+++ b/tests/middleware/test_base.py
@@ -3,7 +3,7 @@ import pytest
 from starlette.applications import Starlette
 from starlette.middleware import Middleware
 from starlette.middleware.base import BaseHTTPMiddleware
-from starlette.responses import PlainTextResponse
+from starlette.responses import PlainTextResponse, StreamingResponse
 from starlette.routing import Route
 
 
@@ -28,6 +28,16 @@ def exc(request):
     raise Exception("Exc")
 
 
+@app.route("/exc-stream")
+def exc_stream(request):
+    return StreamingResponse(_generate_faulty_stream())
+
+
+def _generate_faulty_stream():
+    yield b"Ok"
+    raise Exception("Faulty Stream")
+
+
 @app.route("/no-response")
 class NoResponse:
     def __init__(self, scope, receive, send):
@@ -56,6 +66,10 @@ def test_custom_middleware(test_client_factory):
         response = client.get("/exc")
     assert str(ctx.value) == "Exc"
 
+    with pytest.raises(Exception) as ctx:
+        response = client.get("/exc-stream")
+    assert str(ctx.value) == "Faulty Stream"
+
     with pytest.raises(RuntimeError):
         response = client.get("/no-response")
 
@@ -158,3 +172,13 @@ def test_fully_evaluated_response(test_client_factory):
     client = test_client_factory(app)
     response = client.get("/does_not_exist")
     assert response.text == "Custom"
+
+
+def test_exception_on_mounted_apps(test_client_factory):
+    sub_app = Starlette(routes=[Route("/", exc)])
+    app.mount("/sub", sub_app)
+
+    client = test_client_factory(app)
+    with pytest.raises(Exception) as ctx:
+        client.get("/sub/")
+    assert str(ctx.value) == "Exc"