Coverage for src/lexigram/web/server/shutdown.py: 33%
57 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Graceful shutdown management for the web server.
3Provides GracefulShutdownManager for connection draining.
4"""
6from __future__ import annotations
8import asyncio
9from contextlib import asynccontextmanager
10from typing import TYPE_CHECKING, Any
12if TYPE_CHECKING:
13 from starlette.types import ASGIApp, Receive, Scope, Send
16class GracefulShutdownManager:
17 """Manages graceful shutdown with connection draining."""
19 def __init__(self, timeout: float = 30.0):
20 self._timeout = timeout
21 self._active_connections: int = 0
22 self._shutting_down: bool = False
23 self._drain_event: asyncio.Event | None = None
25 @property
26 def is_shutting_down(self) -> bool:
27 """Check if shutdown is in progress."""
28 return self._shutting_down
30 @property
31 def active_connections(self) -> int:
32 """Get the number of active connections."""
33 return self._active_connections
35 async def wait_for_drain(self) -> None:
36 """Wait for active connections to complete or timeout."""
37 if self._drain_event is None:
38 self._drain_event = asyncio.Event()
40 try:
41 await asyncio.wait_for(
42 self._drain_event.wait(),
43 timeout=self._timeout,
44 )
45 except TimeoutError:
46 # Timeout reached, proceed with shutdown anyway
47 pass
49 def begin_shutdown(self) -> None:
50 """Begin the shutdown process."""
51 self._shutting_down = True
52 if self._drain_event:
53 # If no active connections, signal immediately
54 if self._active_connections == 0:
55 self._drain_event.set()
57 def complete_shutdown(self) -> None:
58 """Complete the shutdown process."""
59 if self._drain_event:
60 self._drain_event.set()
62 @asynccontextmanager
63 async def track_connection(self) -> Any:
64 """Context manager to track active connections."""
65 self._active_connections += 1
66 try:
67 yield
68 finally:
69 self._active_connections -= 1
70 # If shutting down and no more connections, signal
71 if self._shutting_down and self._active_connections == 0:
72 if self._drain_event:
73 self._drain_event.set()
75 async def serve_503_during_drain(self, request: Any) -> Any:
76 """Return a 503 response during drain period."""
77 from starlette.responses import JSONResponse
79 return JSONResponse(
80 content={
81 "error": "service_unavailable",
82 "message": "Server is shutting down",
83 },
84 status_code=503,
85 headers={"Retry-After": str(int(self._timeout))},
86 )
89class ShutdownMiddleware:
90 """Middleware that tracks connections for graceful shutdown."""
92 def __init__(self, app: ASGIApp, shutdown_manager: GracefulShutdownManager) -> None:
93 self.app = app
94 self.manager = shutdown_manager
96 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
97 if scope["type"] != "http":
98 await self.app(scope, receive, send)
99 return
101 # If shutting down, return 503
102 if self.manager.is_shutting_down:
103 response = await self.manager.serve_503_during_drain(None)
104 await response(scope, receive, send)
105 return
107 # Track the connection
108 async with self.manager.track_connection():
109 await self.app(scope, receive, send)