Skip to content

Commit fac641a

Browse files
authored
fix: asgi lifespan msg after lifespan context exception (#3315)
An exception raised within an asgi lifespan context manager would result in a "lifespan.startup.failed" message being sent after we've already sent a "lifespan.startup.complete" message. This would cause uvicorn to raise a `STATE_TRANSITION_ERROR` assertion error due to their [check for that condition][1]. This PR modifies `ASGIRouter.lifespan()` so that it sends a shutdown failure message if we've already confirmed startup. This is consistent with [starlette's behavior][2] under the same conditions. [1]: https://github.com/encode/uvicorn/blob/a2219eb2ed2bbda4143a0fb18c4b0578881b1ae8/uvicorn/lifespan/on.py#L115-L117 [2]: https://github.com/encode/starlette/blob/4e453ce91940cc7c995e6c728e3fdf341c039056/starlette/routing.py#L744-L745
1 parent b5d9c6f commit fac641a

File tree

2 files changed

+40
-8
lines changed

2 files changed

+40
-8
lines changed

litestar/_asgi/asgi_router.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,27 +158,28 @@ async def lifespan(self, receive: LifeSpanReceive, send: LifeSpanSend) -> None:
158158
Returns:
159159
None.
160160
"""
161-
162-
message = await receive()
163161
shutdown_event: LifeSpanShutdownCompleteEvent = {"type": "lifespan.shutdown.complete"}
164162
startup_event: LifeSpanStartupCompleteEvent = {"type": "lifespan.startup.complete"}
165163

164+
await receive()
165+
166+
started = False
166167
try:
167168
async with self.app.lifespan():
168169
await send(startup_event)
169-
message = await receive()
170+
started = True
171+
await receive()
170172

171173
except BaseException as e:
172174
formatted_exception = format_exc()
173175
failure_message: LifeSpanStartupFailedEvent | LifeSpanShutdownFailedEvent
174176

175-
if message["type"] == "lifespan.startup":
176-
failure_message = {"type": "lifespan.startup.failed", "message": formatted_exception}
177-
else:
177+
if started:
178178
failure_message = {"type": "lifespan.shutdown.failed", "message": formatted_exception}
179+
else:
180+
failure_message = {"type": "lifespan.startup.failed", "message": formatted_exception}
179181

180182
await send(failure_message)
181-
182183
raise e
183184

184185
await send(shutdown_event)

tests/unit/test_asgi_router.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from contextlib import asynccontextmanager
44
from typing import TYPE_CHECKING, AsyncGenerator, Callable
5-
from unittest.mock import AsyncMock, MagicMock
5+
from unittest.mock import AsyncMock, MagicMock, call
66

7+
import anyio
78
import pytest
89
from pytest_mock import MockerFixture
910

@@ -194,3 +195,33 @@ async def on_shutdown() -> None:
194195

195196
assert send.call_count == 2
196197
assert send.call_args_list[1][0][0] == {"type": "lifespan.shutdown.failed", "message": mock_format_exc.return_value}
198+
199+
200+
async def test_lifespan_context_exception_after_startup(mock_format_exc: MagicMock) -> None:
201+
receive = AsyncMock()
202+
receive.return_value = {"type": "lifespan.startup"}
203+
send = AsyncMock()
204+
mock_format_exc.return_value = "foo"
205+
206+
async def sleep_and_raise() -> None:
207+
await anyio.sleep(0)
208+
raise RuntimeError("An error occurred")
209+
210+
@asynccontextmanager
211+
async def lifespan(_: Litestar) -> AsyncGenerator[None, None]:
212+
async with anyio.create_task_group() as tg:
213+
tg.start_soon(sleep_and_raise)
214+
yield
215+
216+
router = ASGIRouter(app=Litestar(lifespan=[lifespan]))
217+
218+
with pytest.raises(_ExceptionGroup):
219+
await router.lifespan(receive, send)
220+
221+
assert receive.call_count == 2
222+
send.assert_has_calls(
223+
[
224+
call({"type": "lifespan.startup.complete"}),
225+
call({"type": "lifespan.shutdown.failed", "message": mock_format_exc.return_value}),
226+
]
227+
)

0 commit comments

Comments
 (0)