Source code for genro_asgi.lifespan

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""ASGI lifespan protocol: ordered startup, reverse shutdown, error isolation.

``Lifespan`` is constructed with the server it manages (dual parent-child:
``self.server``, SPECIFICATION.md §4). On ``lifespan.startup`` it runs
``on_startup`` on the server's applications in registration order; on
``lifespan.shutdown`` it runs ``on_shutdown`` in REVERSE order. Hooks may
be sync or async, detected with ``inspect.iscoroutinefunction`` at call time.

A hook that raises is logged and the sequence CONTINUES: one app's error
never blocks the others, and uvicorn always receives the matching
``.complete`` message — app errors are isolated, never abort the protocol.
``FatalBootError`` is the ONE exception to that isolation: an ``on_startup``
hook raises it to declare its failure fatal, the startup stops there and
uvicorn receives ``lifespan.startup.failed``, so the server exits instead
of running without what the hook was there to build.

**The server's lifecycle states live here** — the lifespan is the lifecycle.
``RUNNING`` takes new requests in charge; anything else refuses them with 503.
The shutdown is where the state turns: BEFORE any application's hook runs, the
server stops accepting — ``QUITTING`` when whoever triggered the shutdown chose
to save (``shutdown_mode``, set by the ``--reload`` launcher and one day by
the deliberate command), ``STOPPING`` otherwise — and the in-flight requests are
drained, bounded by ``SHUTDOWN_DRAIN_TIMEOUT_SECONDS``. Only then do the hooks
run, in reverse order: each application saves AFTER nothing new can arrive and
nothing old is still being served. A state somebody already set is respected:
the deliberate command decides before the shutdown reaches here.
"""

from __future__ import annotations

import inspect
import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .application import BaseApplication
    from .server import BaseServer
    from .types import Receive, Scope, Send

RUNNING = "running"
"""The server takes new requests in charge. Any other state refuses them."""

QUITTING = "quitting"
"""The server is leaving and saving what it holds."""

STOPPING = "stopping"
"""The server is leaving without saving."""

#: How long the shutdown waits for the in-flight requests before proceeding
#: without them, in seconds. What is still in flight past it is counted in the
#: log and served by nobody: the worker-level cut answers those calls.
SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 10.0

__all__ = [
    "QUITTING",
    "RUNNING",
    "SHUTDOWN_DRAIN_TIMEOUT_SECONDS",
    "STOPPING",
    "FatalBootError",
    "Lifespan",
]


[docs] class FatalBootError(Exception): """Raised by an ``on_startup`` hook to declare its failure fatal to the server. The one exception ``_run_hook`` does not swallow on startup: the startup stops at the app that raised it and ``Lifespan.__call__`` answers ``lifespan.startup.failed`` (message = the exception text) instead of ``.complete``, so uvicorn exits. On shutdown it gets the ordinary logged-and-continue isolation: nothing may abort the shutdown sequence. """
[docs] class Lifespan: """ASGI lifespan handler, held by the server as a dual parent-child.""" def __init__(self, server: BaseServer) -> None: self.server = server self._logger = logging.getLogger(__name__) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # noqa: ARG002 """Drive the ASGI lifespan protocol: startup then shutdown, both acked. A ``FatalBootError`` out of the startup is the one unacked road: the answer is ``lifespan.startup.failed`` and the protocol ends there. """ while True: message = await receive() if message["type"] == "lifespan.startup": try: await self.startup() except FatalBootError as fatal: await send({"type": "lifespan.startup.failed", "message": str(fatal)}) return await send({"type": "lifespan.startup.complete"}) elif message["type"] == "lifespan.shutdown": await self.shutdown() await send({"type": "lifespan.shutdown.complete"}) return
[docs] async def startup(self) -> None: """Run ``on_startup`` in registration order; ``FatalBootError`` stops it.""" for app in self._apps(): await self._run_hook(app, "on_startup")
[docs] async def shutdown(self) -> None: """Stop accepting, drain what is in flight, THEN run the hooks in reverse. The state turns first — to ``shutdown_mode`` when it is still ``RUNNING``, and it stays untouched when somebody already chose — so no application saves while new work can still arrive. The drain is bounded: past ``SHUTDOWN_DRAIN_TIMEOUT_SECONDS`` the count still in flight goes in the log and the sequence proceeds — those calls are answered by the worker-level cut, never waited for twice. """ if self.server.state == RUNNING: self.server.state = self.server.shutdown_mode still_in_flight = await self.server.requests.await_drain(SHUTDOWN_DRAIN_TIMEOUT_SECONDS) if still_in_flight: self._logger.warning( "Shutdown: %s request(s) still in flight after %.1fs — proceeding", still_in_flight, SHUTDOWN_DRAIN_TIMEOUT_SECONDS, ) for app in reversed(self._apps()): await self._run_hook(app, "on_shutdown")
def _apps(self) -> list[BaseApplication]: """The server's applications, in registration order.""" return list(self.server.applications.values()) async def _run_hook(self, app: BaseApplication, name: str) -> None: """Call ``app``'s hook; a raise is logged, the sequence continues. ``FatalBootError`` from ``on_startup`` propagates instead — the hook declared the server must not start. From ``on_shutdown`` it is an ordinary error: nothing may abort the shutdown sequence. """ handler = getattr(app, name) try: if inspect.iscoroutinefunction(handler): await handler() else: handler() except FatalBootError: if name == "on_startup": raise self._logger.exception("%s.%s raised", type(app).__name__, name) except Exception: self._logger.exception("%s.%s raised", type(app).__name__, name)