Report startup validation failures without tracebacks

This commit is contained in:
Alishahryar1
2026-04-30 00:43:43 -07:00
parent 85232a3ccb
commit d9040ce901
5 changed files with 114 additions and 17 deletions
+63 -7
View File
@@ -9,13 +9,14 @@ from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from starlette.types import Receive, Scope, Send
from config.logging_config import configure_logging
from config.settings import get_settings
from providers.exceptions import ProviderError
from .routes import router
from .runtime import AppRuntime
from .runtime import AppRuntime, startup_failure_message
from .validation_log import summarize_request_validation_body
@@ -30,18 +31,68 @@ async def lifespan(app: FastAPI):
await runtime.shutdown()
def create_app() -> FastAPI:
class GracefulLifespanApp:
"""ASGI wrapper that reports startup failures without Starlette tracebacks."""
def __init__(self, app: FastAPI):
self.app = app
def __getattr__(self, name: str) -> Any:
return getattr(self.app, name)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "lifespan":
await self.app(scope, receive, send)
return
await self._lifespan(receive, send)
async def _lifespan(self, receive: Receive, send: Send) -> None:
settings = get_settings()
runtime = AppRuntime.for_app(self.app, settings=settings)
startup_complete = False
while True:
message = await receive()
if message["type"] == "lifespan.startup":
try:
await runtime.startup()
except Exception as exc:
await send(
{
"type": "lifespan.startup.failed",
"message": startup_failure_message(settings, exc),
}
)
return
startup_complete = True
await send({"type": "lifespan.startup.complete"})
continue
if message["type"] == "lifespan.shutdown":
if startup_complete:
try:
await runtime.shutdown()
except Exception as exc:
logger.error("Shutdown failed: exc_type={}", type(exc).__name__)
await send({"type": "lifespan.shutdown.failed", "message": ""})
return
await send({"type": "lifespan.shutdown.complete"})
return
def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
"""Create and configure the FastAPI application."""
settings = get_settings()
configure_logging(
settings.log_file, verbose_third_party=settings.log_raw_api_payloads
)
app = FastAPI(
title="Claude Code Proxy",
version="2.0.0",
lifespan=lifespan,
)
app_kwargs: dict[str, Any] = {
"title": "Claude Code Proxy",
"version": "2.0.0",
}
if lifespan_enabled:
app_kwargs["lifespan"] = lifespan
app = FastAPI(**app_kwargs)
# Register routes
app.include_router(router)
@@ -117,3 +168,8 @@ def create_app() -> FastAPI:
)
return app
def create_asgi_app() -> GracefulLifespanApp:
"""Create the server ASGI app with graceful lifespan failure reporting."""
return GracefulLifespanApp(create_app(lifespan_enabled=False))
+9 -6
View File
@@ -64,16 +64,19 @@ def warn_if_process_auth_token(settings: Settings) -> None:
def log_startup_failure(settings: Settings, exc: Exception) -> None:
"""Log startup failures without traceback noise unless verbose diagnostics are enabled."""
message = startup_failure_message(settings, exc)
logger.error("Startup failed:\n{}", message)
def startup_failure_message(settings: Settings, exc: Exception) -> str:
"""Return a concise startup failure message for logs and ASGI lifespan failure."""
if isinstance(exc, ServiceUnavailableError):
message = exc.message.strip() or "Server startup failed."
logger.error("Startup failed:\n{}", message)
return
return exc.message.strip() or "Server startup failed."
if settings.log_api_error_tracebacks:
logger.error("Startup failed: {}: {}", type(exc).__name__, exc)
return
return f"{type(exc).__name__}: {exc}"
logger.error("Startup failed: exc_type={}", type(exc).__name__)
return f"Server startup failed: exc_type={type(exc).__name__}"
@dataclass(slots=True)
+1 -1
View File
@@ -30,7 +30,7 @@ def serve() -> None:
settings = get_settings()
try:
uvicorn.run(
"api.app:create_app",
"api.app:create_asgi_app",
factory=True,
host=settings.host,
port=settings.port,
+2 -2
View File
@@ -5,9 +5,9 @@ Minimal entry point that builds the ASGI app via :func:`api.app.create_app`.
Run with: uv run uvicorn server:app --host 0.0.0.0 --port 8082 --timeout-graceful-shutdown 5
"""
from api.app import create_app
from api.app import create_app, create_asgi_app
app = create_app()
app = create_asgi_app()
__all__ = ["app", "create_app"]
+39 -1
View File
@@ -1,6 +1,7 @@
import importlib
from collections.abc import MutableMapping
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -397,6 +398,43 @@ async def test_runtime_startup_validation_blocks_messaging_and_cleans_up(tmp_pat
assert "Traceback" not in logged
@pytest.mark.asyncio
async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
import api.app as api_app_mod
settings = _app_settings(
messaging_platform="none",
telegram_bot_token=None,
allowed_telegram_user_id=None,
discord_bot_token=None,
allowed_discord_channels=None,
allowed_dir=str(tmp_path / "workspace"),
claude_workspace=str(tmp_path / "data"),
host="127.0.0.1",
port=8082,
log_file=str(tmp_path / "server.log"),
)
app = api_app_mod.GracefulLifespanApp(FastAPI())
sent: list[MutableMapping[str, Any]] = []
async def receive() -> MutableMapping[str, Any]:
return {"type": "lifespan.startup"}
async def send(message: MutableMapping[str, Any]) -> None:
sent.append(message)
validation = AsyncMock(side_effect=ServiceUnavailableError("bad model"))
cleanup = AsyncMock()
with (
patch.object(api_app_mod, "get_settings", return_value=settings),
patch.object(ProviderRegistry, "validate_configured_models", new=validation),
patch.object(ProviderRegistry, "cleanup", new=cleanup),
):
await app({"type": "lifespan"}, receive, send)
assert sent == [{"type": "lifespan.startup.failed", "message": "bad model"}]
def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog):
"""Messaging import failure logs warning and continues without crash."""
from api.app import create_app