Coverage for gco / services / inference_api.py: 100.00%
54 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Dedicated authenticated streaming proxy API for managed inference endpoints."""
3from __future__ import annotations
5import logging
6import os
7from collections.abc import AsyncIterator
8from contextlib import asynccontextmanager
9from typing import Any
11from fastapi import FastAPI, Request
12from fastapi.responses import JSONResponse
14from gco.services import tracing
15from gco.services.api_routes.inference_proxy import router as inference_proxy_router
16from gco.services.auth_middleware import AuthenticationMiddleware
17from gco.services.request_size_middleware import (
18 DEFAULT_MAX_REQUEST_BODY_BYTES,
19 RequestSizeLimitMiddleware,
20)
21from gco.services.service_metrics import mount_metrics
23logging.basicConfig(
24 level=logging.INFO,
25 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
26)
27logger = logging.getLogger(__name__)
29DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 900
32@asynccontextmanager
33async def lifespan(app: FastAPI) -> AsyncIterator[None]:
34 """Flush queued spans at shutdown; startup has nothing to prepare.
36 The proxy keeps no process-wide upstream state: every request builds its
37 model-endpoint client and releases it when its stream ends, so shutdown
38 has no client to close. Uvicorn runs this shutdown only after it has
39 drained (or, past the graceful budget, cancelled) the in-flight streams,
40 so the spans of the last requests are already queued when the provider
41 flushes.
42 """
43 try:
44 yield
45 finally:
46 tracing.shutdown_tracing()
49app = FastAPI(
50 title="GCO Inference Proxy API",
51 description="Authenticated streaming reverse proxy for managed GCO inference endpoints",
52 version="1.0.0",
53 lifespan=lifespan,
54)
56# Server spans for every route except the probe and scrape paths, with the
57# model-endpoint client spans beneath them; inert unless GCO_TRACING_ENABLED=true.
58tracing.configure_tracing("inference-proxy")
59tracing.instrument_fastapi_app(app)
61# Starlette executes middleware in reverse registration order. The size limit
62# runs first so an oversized request is rejected before secret retrieval or
63# HMAC work; AuthenticationMiddleware still validates the exact cached body.
64app.add_middleware(AuthenticationMiddleware)
65_max_body_bytes = int(os.getenv("MAX_REQUEST_BODY_BYTES", str(DEFAULT_MAX_REQUEST_BODY_BYTES)))
66app.add_middleware(RequestSizeLimitMiddleware, max_body_bytes=_max_body_bytes)
68mount_metrics(app, "inference-proxy")
70app.include_router(inference_proxy_router)
73@app.get("/", tags=["Info"])
74async def root() -> dict[str, Any]:
75 """Return a small authenticated service descriptor."""
76 return {
77 "service": "GCO Inference Proxy API",
78 "version": "1.0.0",
79 "status": "running",
80 "region": os.getenv("REGION", "unknown"),
81 }
84@app.get("/healthz", tags=["Health"])
85async def kubernetes_health_check() -> dict[str, str]:
86 """Kubernetes and ALB liveness probe."""
87 return {"status": "ok"}
90@app.get("/readyz", tags=["Health"])
91async def kubernetes_readiness_check() -> dict[str, str]:
92 """Readiness probe; external dependencies are resolved per request."""
93 return {"status": "ready"}
96@app.exception_handler(Exception)
97async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
98 """Log unexpected failures without exposing internal routing details.
100 ``exc`` is intentionally unused: the traceback comes from the active
101 exception, and the response deliberately says nothing about it.
102 """
103 # LOG004 is suppressed because this IS an exception handler. Starlette
104 # awaits it from inside its own `except` block, so the exception is still
105 # active and `.exception()` records the traceback; ruff only sees that the
106 # call is not lexically inside an `except`. Downgrading to `.error()` as it
107 # suggests would silently drop the traceback that makes a 500 debuggable.
108 logger.exception( # noqa: LOG004
109 "Unhandled inference proxy exception for %s %s", request.method, request.url.path
110 )
111 return JSONResponse(status_code=500, content={"error": "Internal server error"})
114def create_app() -> FastAPI:
115 """Return the process-wide FastAPI application."""
116 return app
119def _run_server() -> None:
120 """Run Uvicorn with the same drain budget declared by the pod manifest."""
121 import uvicorn
123 from gco.services.uvicorn_runtime import describe_uvicorn_runtime
125 host = os.getenv("HOST", "0.0.0.0") # container listener
126 port = int(os.getenv("PORT", "8080"))
127 log_level = os.getenv("LOG_LEVEL", "info").lower()
128 graceful_shutdown_seconds = int(
129 os.getenv(
130 "GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS",
131 str(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS),
132 )
133 )
134 loop_impl, http_impl = describe_uvicorn_runtime()
135 logger.info(
136 "Starting Inference Proxy API on %s:%d (uvicorn loop=%s http=%s)",
137 host,
138 port,
139 loop_impl,
140 http_impl,
141 )
142 uvicorn.run(
143 "gco.services.inference_api:app",
144 host=host,
145 port=port,
146 log_level=log_level,
147 reload=False,
148 timeout_graceful_shutdown=graceful_shutdown_seconds,
149 )
152if __name__ == "__main__":
153 _run_server()