Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent anyio.ExceptionGroup in error views under a BaseHTTPMiddleware #1262

Merged
merged 5 commits into from
Oct 28, 2021

Conversation

florimondmanca
Copy link
Member

@florimondmanca florimondmanca commented Aug 11, 2021

Closes #1255

Motivation
#1157 switched to using anyio for async concurrency operations.

But now when a view raises an exception, say MyExc, and a BaseHTTPMiddleware is installed, calling the view would make Starlette raise an anyio.ExceptionGroup, rather than MyExc directly.

Previously, under asyncio, we were calling task.result() before raising RuntimeError("No response sent."), so only MyExc was raised, as expected, see here:

task.result()
raise RuntimeError("No response returned.")

This PR switches back to that behavior by adding a checkpoint (equivalent to asyncio.sleep(0) — i.e. "allow switching tasks and checking for cancellation) before raising the "No response sent" exception.

cc @uSpike

@florimondmanca florimondmanca requested a review from uSpike August 11, 2021 09:21
@florimondmanca florimondmanca changed the title Unwrap view errors occurring under a BaseHTTPMiddleware Unwrap anyio.ExceptionGroup occurring when calling an error view under a BaseHTTPMiddleware Aug 11, 2021
@florimondmanca florimondmanca force-pushed the fm/anyio-middleware-view-error branch from ed4246f to 525215a Compare August 11, 2021 10:15
@florimondmanca florimondmanca changed the title Unwrap anyio.ExceptionGroup occurring when calling an error view under a BaseHTTPMiddleware Prevent anyio.ExceptionGroup in error views under a BaseHTTPMiddleware Aug 11, 2021
@@ -52,8 +52,9 @@ def test_custom_middleware(test_client_factory):
response = client.get("/")
assert response.headers["Custom-Header"] == "Example"

with pytest.raises(Exception):
with pytest.raises(Exception) as ctx:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When running on trio, previously we would be getting an unexpected "Portal is not running" error here, and misinterpreting it as the Exception() in the /exc view. So I made the latter more precise and made sure we're explicitly checking for it.

@florimondmanca florimondmanca added bug Something isn't working clean up Refinement to existing functionality labels Aug 11, 2021
@florimondmanca florimondmanca force-pushed the fm/anyio-middleware-view-error branch from 525215a to 58defbc Compare August 11, 2021 10:19
@florimondmanca florimondmanca requested a review from a team August 11, 2021 10:22
@uSpike
Copy link
Member

uSpike commented Aug 11, 2021

I'm not sure I'm in favor of relying on event loop magic to correctly propagate exceptions.

Here's an idea that I think is a bit easier to follow, thoughts?:

diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py
index bf337b8..30ae1ed 100644
--- a/starlette/middleware/base.py
+++ b/starlette/middleware/base.py
@@ -24,21 +24,25 @@ class BaseHTTPMiddleware:
 
         async def call_next(request: Request) -> Response:
             send_stream, recv_stream = anyio.create_memory_object_stream()
+            app_exc: typing.Optional[Exception] = None
 
             async def coro() -> None:
                 async with send_stream:
-                    await self.app(scope, request.receive, send_stream.send)
+                    try:
+                        await self.app(scope, request.receive, send_stream.send)
+                    except Exception as exc:
+                        nonlocal app_exc
+                        app_exc = exc
 
             task_group.start_soon(coro)
 
             try:
                 message = await recv_stream.receive()
             except anyio.EndOfStream:
-                # HACK: give anyio a chance to surface any app exception first,
-                # in order to avoid an `anyio.ExceptionGroup`.
-                # See #1255.
-                await anyio.lowlevel.checkpoint()
-                raise RuntimeError("No response returned.")
+                if app_exc is not None:
+                    raise app_exc
+                else:
+                    raise RuntimeError("No response returned.")
 
             assert message["type"] == "http.response.start"

@Kludex Kludex mentioned this pull request Sep 26, 2021
@simondale00
Copy link

Hey team. Anything I can do to help push this along?

I'm running into this issue as well, which is impacting Exception handling. Specifically, adding any user middleware breaks ServerErrorMiddleware, as ServerErrorMiddleware is not built to catch ExceptionGroup. (While one could argue ServerErrorMiddleware could be modified to handle exception groups, I think the core issue here is that BaseHTTPMiddleware dismisses the possibility that a callable raises an exception and always expects to read a response)

I've tested out the proposal from @uSpike and it works well. Also seems like a pragmatic approach to resolve the issue.

When can we get this merged and released?

@florimondmanca
Copy link
Member Author

Looks like this is ready then — happy to have anyone review and release this.

Copy link

@simondale00 simondale00 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Thanks, @florimondmanca!

@kissgyorgy
Copy link

Hello, we will be using this fix at our company too, looks good.

@Kludex Kludex mentioned this pull request Oct 28, 2021
2 tasks
@florimondmanca
Copy link
Member Author

@Kludex Hi, saw you open #1324. I'd need a ✅ around here to merge. Thanks!

Copy link
Member

@Kludex Kludex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solution looks much simpler 👍

I was waiting to see if @uSpike wanted to say something, but the code looks pretty straightforward. 😄

@florimondmanca
Copy link
Member Author

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working clean up Refinement to existing functionality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

test_custom_middleware[trio] fails on 1-core-VM
6 participants