"""The app factory and its lifespan. `create_app(settings)` is a factory, not a module-level `app = FastAPI()`, for one reason: a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real environment at import time — before any fixture can say otherwise. """ from __future__ import annotations import asyncio import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from jwt import PyJWKClient from services.api.models import ErrorBody from services.api.routes import health, instances from svcforge_core.domain.catalog import load_catalog from svcforge_core.repo.db import make_pool from svcforge_core.settings import Settings, load_settings log = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Open the pool, yield, close the pool. A lifespan context, not the deprecated startup/shutdown event decorators: those cannot express "this resource lives for exactly as long as the app", and give you no place to put the teardown next to the setup. Closing the pool matters — an unclosed pool means connections linger server-side after SIGTERM, and on a pooled Postgres with a small connection budget a few rolling deploys exhaust it. (The old decorator's name is spelled nowhere in this package on purpose: CI greps for the literal string, and a comment quoting it fails the gate just as loudly as a call.) """ settings: Settings = app.state.settings app.state.catalog = load_catalog(settings.catalog_path) pool = make_pool(str(settings.pg_dsn), settings.pool_min_size, settings.pool_max_size) # wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request, # as a PoolTimeout, in front of a user. await pool.open(wait=True) app.state.pool = pool # The pool is open from here on, so everything below is inside the try: an exception # in JWKS setup must still close it, or a crash-looping pod leaks a connection per # restart until the database refuses new ones. try: if settings.jwks_url and not settings.auth_disabled: client = PyJWKClient(settings.jwks_url, cache_keys=True, lifespan=300) app.state.jwks_client = client # Warm the cache off the loop so the first authenticated request does not pay # a blocking urlopen. Best-effort: a slow identity provider must not stop the # pod from starting — a cache miss later just costs one to_thread hop. try: await asyncio.to_thread(client.get_signing_keys) except Exception: # deliberate catch-all: startup must not hinge on the IdP being up log.warning("JWKS warm-up failed; keys will be fetched on first use", exc_info=True) else: app.state.jwks_client = None yield finally: await pool.close() async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Render HTTPException bodies as ErrorBody, so every error has one shape. Handlers raise `detail={"code": ..., "message": ...}`; FastAPI's default would nest that under `{"detail": {...}}`. Plain-string details (raised by FastAPI itself, e.g. a 405) are wrapped so clients never have to branch on the body's type. """ assert isinstance(exc, HTTPException) # noqa: S101 - registered only for HTTPException # Widened to object deliberately. Starlette types `detail` as str, but FastAPI passes # through whatever a handler raised — our handlers raise dicts. Narrowing off the # declared type would make mypy call the dict branch unreachable and delete it. detail: object = exc.detail if isinstance(detail, dict) and "code" in detail and "message" in detail: body = ErrorBody(code=str(detail["code"]), message=str(detail["message"])) else: body = ErrorBody(code=f"http_{exc.status_code}", message=str(detail)) return JSONResponse(status_code=exc.status_code, content=body.model_dump(), headers=exc.headers) def create_app(settings: Settings | None = None) -> FastAPI: """App factory: lifespan, routers, exception handler, /metrics.""" settings = settings or load_settings() app = FastAPI( title="svcforge", version="0.1.0", summary="X-as-a-Service control plane", lifespan=lifespan, ) app.state.settings = settings # /metrics is a normal route on health.router, not an app.mount — see health.metrics # for why the mount does not actually serve a bare /metrics. app.include_router(health.router) app.include_router(instances.router) app.add_exception_handler(HTTPException, _http_exception_handler) return app def app() -> FastAPI: """Entry point for `uvicorn services.api.main:app --factory`.""" return create_app() if __name__ == "__main__": # pragma: no cover import uvicorn uvicorn.run("services.api.main:app", factory=True, host="0.0.0.0", port=8000) # noqa: S104