From 7fb0022666d7f52f7eb19e07e8029b50f00a0158 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 15 Dec 2022 20:46:48 +0200 Subject: [PATCH 1/3] ASGI lifespan failure on exception --- sanic/asgi.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/sanic/asgi.py b/sanic/asgi.py index c3d669e73b..d8ab4cfaf7 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -9,7 +9,7 @@ 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 @@ async def __call__( ) -> 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: From 060a195d168f277938c1f458eada0e255d4f22f9 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 15 Dec 2022 22:32:27 +0200 Subject: [PATCH 2/3] Add tests --- tests/test_asgi.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index e612e3fed5..cd6653ce39 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,14 +1,16 @@ import asyncio import logging +from ast import Try from collections import deque, namedtuple +from unittest.mock import AsyncMock, call import pytest 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 @@ -558,3 +560,39 @@ def _request(request: Request): _, 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"} + ) From f9358ef49ec6085cd0e4aba30c5784391e7eef1c Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 15 Dec 2022 22:37:39 +0200 Subject: [PATCH 3/3] 3.7 AsyncMock --- tests/test_asgi.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index cd6653ce39..0c76a67f3c 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,9 +1,7 @@ import asyncio import logging -from ast import Try from collections import deque, namedtuple -from unittest.mock import AsyncMock, call import pytest import uvicorn @@ -18,6 +16,12 @@ 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()