From a3ff0c13b745d6f3eee9cc4ac00f2f95331933c9 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Fri, 16 Dec 2022 08:56:07 +0200 Subject: [PATCH] ASGI lifespan failure on exception (#2627) --- sanic/asgi.py | 24 +++++++++++++++++++----- tests/test_asgi.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/sanic/asgi.py b/sanic/asgi.py index c3d669e7..d8ab4cfa 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -9,7 +9,7 @@ from sanic.compat import Header from sanic.exceptions import ServerError from sanic.helpers import Default from sanic.http import Stage -from sanic.log import logger +from sanic.log import error_logger, logger from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport from sanic.request import Request from sanic.response import BaseHTTPResponse @@ -85,13 +85,27 @@ class Lifespan: ) -> None: message = await receive() if message["type"] == "lifespan.startup": - await self.startup() - await send({"type": "lifespan.startup.complete"}) + try: + await self.startup() + except Exception as e: + error_logger.exception(e) + await send( + {"type": "lifespan.startup.failed", "message": str(e)} + ) + else: + await send({"type": "lifespan.startup.complete"}) message = await receive() if message["type"] == "lifespan.shutdown": - await self.shutdown() - await send({"type": "lifespan.shutdown.complete"}) + try: + await self.shutdown() + except Exception as e: + error_logger.exception(e) + await send( + {"type": "lifespan.shutdown.failed", "message": str(e)} + ) + else: + await send({"type": "lifespan.shutdown.complete"}) class ASGIApp: diff --git a/tests/test_asgi.py b/tests/test_asgi.py index e612e3fe..0c76a67f 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -8,7 +8,7 @@ import uvicorn from sanic import Sanic from sanic.application.state import Mode -from sanic.asgi import MockTransport +from sanic.asgi import ASGIApp, MockTransport from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable from sanic.request import Request from sanic.response import json, text @@ -16,6 +16,12 @@ from sanic.server.websockets.connection import WebSocketConnection from sanic.signals import RESERVED_NAMESPACES +try: + from unittest.mock import AsyncMock +except ImportError: + from tests.asyncmock import AsyncMock # type: ignore + + @pytest.fixture def message_stack(): return deque() @@ -558,3 +564,39 @@ async def test_asgi_serve_location(app): _, response = await app.asgi_client.get("/") assert response.text == "http://" + + +@pytest.mark.asyncio +async def test_error_on_lifespan_exception_start(app, caplog): + @app.before_server_start + async def before_server_start(_): + 1 / 0 + + recv = AsyncMock(return_value={"type": "lifespan.startup"}) + send = AsyncMock() + app.asgi = True + + with caplog.at_level(logging.ERROR): + await ASGIApp.create(app, {"type": "lifespan"}, recv, send) + + send.assert_awaited_once_with( + {"type": "lifespan.startup.failed", "message": "division by zero"} + ) + + +@pytest.mark.asyncio +async def test_error_on_lifespan_exception_stop(app: Sanic): + @app.before_server_stop + async def before_server_stop(_): + 1 / 0 + + recv = AsyncMock(return_value={"type": "lifespan.shutdown"}) + send = AsyncMock() + app.asgi = True + await app._startup() + + await ASGIApp.create(app, {"type": "lifespan"}, recv, send) + + send.assert_awaited_once_with( + {"type": "lifespan.shutdown.failed", "message": "division by zero"} + )