Skip to content

src/genro_asgi/lifespan.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """ASGI lifespan protocol: ordered startup, reverse shutdown, error isolation.16 17 ``Lifespan`` is constructed with the server it manages (dual parent-child:18 ``self.server``, SPECIFICATION.md §4). On ``lifespan.startup`` it runs19 ``on_startup`` on the server's applications in registration order; on20 ``lifespan.shutdown`` it runs ``on_shutdown`` in REVERSE order. Hooks may21 be sync or async, detected with ``inspect.iscoroutinefunction`` at call time.22 23 A hook that raises is logged and the sequence CONTINUES: one app's error24 never blocks the others, and uvicorn always receives the matching25 ``.complete`` message — app errors are isolated, never abort the protocol.26 ``FatalBootError`` is the ONE exception to that isolation: an ``on_startup``27 hook raises it to declare its failure fatal, the startup stops there and28 uvicorn receives ``lifespan.startup.failed``, so the server exits instead29 of running without what the hook was there to build.30 31 **The server's lifecycle states live here** — the lifespan is the lifecycle.32 ``RUNNING`` takes new requests in charge; anything else refuses them with 503.33 The shutdown is where the state turns: BEFORE any application's hook runs, the34 server stops accepting — ``QUITTING`` when whoever triggered the shutdown chose35 to save (``shutdown_mode``, set by the ``--reload`` launcher and one day by36 the deliberate command), ``STOPPING`` otherwise — and the in-flight requests are37 drained, bounded by ``SHUTDOWN_DRAIN_TIMEOUT_SECONDS``. Only then do the hooks38 run, in reverse order: each application saves AFTER nothing new can arrive and39 nothing old is still being served. A state somebody already set is respected:40 the deliberate command decides before the shutdown reaches here.41 """42 43 from __future__ import annotations44 45 import inspect46 import logging47 from typing import TYPE_CHECKING48 49 if TYPE_CHECKING:50     from .application import BaseApplication51     from .server import BaseServer52     from .types import Receive, Scope, Send53 54 RUNNING = "running"55 """The server takes new requests in charge. Any other state refuses them."""56 57 QUITTING = "quitting"58 """The server is leaving and saving what it holds."""59 60 STOPPING = "stopping"61 """The server is leaving without saving."""62 63 #: How long the shutdown waits for the in-flight requests before proceeding64 #: without them, in seconds. What is still in flight past it is counted in the65 #: log and served by nobody: the worker-level cut answers those calls.66 SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 10.067 68 __all__ = [69     "QUITTING",70     "RUNNING",71     "SHUTDOWN_DRAIN_TIMEOUT_SECONDS",72     "STOPPING",73     "FatalBootError",74     "Lifespan",75 ]76 77 78 class FatalBootError(Exception):79     """Raised by an ``on_startup`` hook to declare its failure fatal to the server.80 81     The one exception ``_run_hook`` does not swallow on startup: the startup82     stops at the app that raised it and ``Lifespan.__call__`` answers83     ``lifespan.startup.failed`` (message = the exception text) instead of84     ``.complete``, so uvicorn exits. On shutdown it gets the ordinary85     logged-and-continue isolation: nothing may abort the shutdown sequence.86     """87 88 89 class Lifespan:90     """ASGI lifespan handler, held by the server as a dual parent-child."""91 92     def __init__(self, server: BaseServer) -> None:93         self.server = server94         self._logger = logging.getLogger(__name__)95 96     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:  # noqa: ARG00297         """Drive the ASGI lifespan protocol: startup then shutdown, both acked.98 99         A ``FatalBootError`` out of the startup is the one unacked road: the100         answer is ``lifespan.startup.failed`` and the protocol ends there.101         """102         while True:103             message = await receive()104             if message["type"] == "lifespan.startup":105                 try:106                     await self.startup()107                 except FatalBootError as fatal:108                     await send({"type": "lifespan.startup.failed", "message": str(fatal)})109                     return110                 await send({"type": "lifespan.startup.complete"})111             elif message["type"] == "lifespan.shutdown":112                 await self.shutdown()113                 await send({"type": "lifespan.shutdown.complete"})114                 return115 116     async def startup(self) -> None:117         """Run ``on_startup`` in registration order; ``FatalBootError`` stops it."""118         for app in self._apps():119             await self._run_hook(app, "on_startup")120 121     async def shutdown(self) -> None:122         """Stop accepting, drain what is in flight, THEN run the hooks in reverse.123 124         The state turns first — to ``shutdown_mode`` when it is still125         ``RUNNING``, and it stays untouched when somebody already chose — so no126         application saves while new work can still arrive. The drain is bounded:127         past ``SHUTDOWN_DRAIN_TIMEOUT_SECONDS`` the count still in flight goes in128         the log and the sequence proceeds — those calls are answered by the129         worker-level cut, never waited for twice.130         """131         if self.server.state == RUNNING:132             self.server.state = self.server.shutdown_mode133         still_in_flight = await self.server.requests.await_drain(SHUTDOWN_DRAIN_TIMEOUT_SECONDS)134         if still_in_flight:135             self._logger.warning(136                 "Shutdown: %s request(s) still in flight after %.1fs — proceeding",137                 still_in_flight,138                 SHUTDOWN_DRAIN_TIMEOUT_SECONDS,139             )140         for app in reversed(self._apps()):141             await self._run_hook(app, "on_shutdown")142 143     def _apps(self) -> list[BaseApplication]:144         """The server's applications, in registration order."""145         return list(self.server.applications.values())146 147     async def _run_hook(self, app: BaseApplication, name: str) -> None:148         """Call ``app``'s hook; a raise is logged, the sequence continues.149 150         ``FatalBootError`` from ``on_startup`` propagates instead — the hook151         declared the server must not start. From ``on_shutdown`` it is an152         ordinary error: nothing may abort the shutdown sequence.153         """154         handler = getattr(app, name)155         try:156             if inspect.iscoroutinefunction(handler):157                 await handler()158             else:159                 handler()160         except FatalBootError:161             if name == "on_startup":162                 raise163             self._logger.exception("%s.%s raised", type(app).__name__, name)164         except Exception:165             self._logger.exception("%s.%s raised", type(app).__name__, name)