Skip to content

Commit 26d66bb

Browse files
authored
Do not pollute exception context in Middleware (#2976)
1 parent a59397d commit 26d66bb

File tree

2 files changed

+69
-1
lines changed

2 files changed

+69
-1
lines changed

starlette/middleware/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,16 @@ async def coro() -> None:
156156
if app_exc is not None:
157157
nonlocal exception_already_raised
158158
exception_already_raised = True
159-
raise app_exc
159+
# Prevent `anyio.EndOfStream` from polluting app exception context.
160+
# If both cause and context are None then the context is suppressed
161+
# and `anyio.EndOfStream` is not present in the exception traceback.
162+
# If exception cause is not None then it is propagated with
163+
# reraising here.
164+
# If exception has no cause but has context set then the context is
165+
# propagated as a cause with the reraise. This is necessary in order
166+
# to prevent `anyio.EndOfStream` from polluting the exception
167+
# context.
168+
raise app_exc from app_exc.__cause__ or app_exc.__context__
160169
raise RuntimeError("No response returned.")
161170

162171
assert message["type"] == "http.response.start"

tests/middleware/test_base.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,3 +1243,62 @@ async def send(message: Message) -> None:
12431243
assert len(events) == 2
12441244
assert events[0]["type"] == "http.response.start"
12451245
assert events[1]["type"] == "http.response.pathsend"
1246+
1247+
1248+
def test_error_context_propagation(test_client_factory: TestClientFactory) -> None:
1249+
class PassthroughMiddleware(BaseHTTPMiddleware):
1250+
async def dispatch(
1251+
self,
1252+
request: Request,
1253+
call_next: RequestResponseEndpoint,
1254+
) -> Response:
1255+
return await call_next(request)
1256+
1257+
def exception_without_context(request: Request) -> None:
1258+
raise Exception("Exception")
1259+
1260+
def exception_with_context(request: Request) -> None:
1261+
try:
1262+
raise Exception("Inner exception")
1263+
except Exception:
1264+
raise Exception("Outer exception")
1265+
1266+
def exception_with_cause(request: Request) -> None:
1267+
try:
1268+
raise Exception("Inner exception")
1269+
except Exception as e:
1270+
raise Exception("Outer exception") from e
1271+
1272+
app = Starlette(
1273+
routes=[
1274+
Route("/exception-without-context", endpoint=exception_without_context),
1275+
Route("/exception-with-context", endpoint=exception_with_context),
1276+
Route("/exception-with-cause", endpoint=exception_with_cause),
1277+
],
1278+
middleware=[Middleware(PassthroughMiddleware)],
1279+
)
1280+
client = test_client_factory(app)
1281+
1282+
# For exceptions without context the context is filled with the `anyio.EndOfStream`
1283+
# but it is suppressed therefore not propagated to traceback.
1284+
with pytest.raises(Exception) as ctx:
1285+
client.get("/exception-without-context")
1286+
assert str(ctx.value) == "Exception"
1287+
assert ctx.value.__cause__ is None
1288+
assert ctx.value.__context__ is not None
1289+
assert ctx.value.__suppress_context__ is True
1290+
1291+
# For exceptions with context the context is propagated as a cause to avoid
1292+
# `anyio.EndOfStream` error from overwriting it.
1293+
with pytest.raises(Exception) as ctx:
1294+
client.get("/exception-with-context")
1295+
assert str(ctx.value) == "Outer exception"
1296+
assert ctx.value.__cause__ is not None
1297+
assert str(ctx.value.__cause__) == "Inner exception"
1298+
1299+
# For exceptions with cause check that it gets correctly propagated.
1300+
with pytest.raises(Exception) as ctx:
1301+
client.get("/exception-with-cause")
1302+
assert str(ctx.value) == "Outer exception"
1303+
assert ctx.value.__cause__ is not None
1304+
assert str(ctx.value.__cause__) == "Inner exception"

0 commit comments

Comments
 (0)