Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import os | |
| import sys | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| if __package__ in {None, ""}: | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from fastapi import FastAPI, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from api.routes import admin, analyses, auth, exercises, health, templates | |
| from core.config import get_settings | |
| from core.logging import ( | |
| bind_request_context, | |
| clear_request_context, | |
| get_logger, | |
| setup_logging, | |
| ) | |
| from services.bootstrap import initialize_runtime | |
| setup_logging() | |
| _log = get_logger("app.api") | |
| DIAGNOSTIC_RESPONSE_HEADERS = [ | |
| "x-request-id", | |
| "x-ai-coach-request-body-wait-ms", | |
| "x-ai-coach-handler-ms", | |
| "x-ai-coach-ingest-mbps-est", | |
| "x-ai-coach-upload-mode", | |
| ] | |
| def _request_metrics(request: Request, request_started_at: float, duration_ms: float) -> dict[str, object]: | |
| route_entered_at = getattr(request.state, "route_entered_at", None) | |
| file_saved_at = getattr(request.state, "analysis_file_saved_at", None) | |
| enqueued_at = getattr(request.state, "analysis_enqueued_at", None) | |
| metrics: dict[str, object] = {} | |
| if route_entered_at is not None: | |
| body_wait_ms = round((route_entered_at - request_started_at) * 1000, 1) | |
| metrics["request_body_wait_ms"] = body_wait_ms | |
| metrics["request_handler_ms"] = round(max(duration_ms - body_wait_ms, 0), 1) | |
| if file_saved_at is not None: | |
| metrics["request_file_save_ms"] = round((file_saved_at - route_entered_at) * 1000, 1) | |
| if enqueued_at is not None: | |
| metrics["request_enqueue_ms"] = round((enqueued_at - route_entered_at) * 1000, 1) | |
| content_length_raw = request.headers.get("content-length") | |
| if content_length_raw and content_length_raw.isdigit(): | |
| content_length = int(content_length_raw) | |
| metrics["content_length"] = content_length | |
| if body_wait_ms > 0: | |
| metrics["ingest_mbps_est"] = round((content_length * 8) / (body_wait_ms / 1000) / 1_000_000, 3) | |
| return metrics | |
| class RequestContextMiddleware(BaseHTTPMiddleware): | |
| """Tag every request with a request_id, log start/end with latency.""" | |
| async def dispatch(self, request: Request, call_next): | |
| request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12] | |
| bind_request_context( | |
| request_id=request_id, | |
| method=request.method, | |
| path=request.url.path, | |
| ) | |
| start = time.perf_counter() | |
| request.state.request_started_at = start | |
| status_code = 500 | |
| try: | |
| response = await call_next(request) | |
| status_code = response.status_code | |
| response.headers["x-request-id"] = request_id | |
| # Cache-Control for static uploads — browser cache avoids re-fetch | |
| path = request.url.path | |
| if path.startswith("/uploads/templates"): | |
| response.headers["Cache-Control"] = "public, max-age=2592000" # 30 days | |
| elif path.startswith("/uploads/"): | |
| response.headers["Cache-Control"] = "public, max-age=604800" # 7 days | |
| return response | |
| except Exception: | |
| _log.exception("request_unhandled_exception") | |
| raise | |
| finally: | |
| duration_ms = round((time.perf_counter() - start) * 1000, 1) | |
| # Skip noisy access logs for static + health. | |
| path = request.url.path | |
| if not (path.startswith("/uploads") or path == "/health"): | |
| extra_metrics = _request_metrics(request, start, duration_ms) | |
| _log.info( | |
| "request_finished", | |
| status=status_code, | |
| duration_ms=duration_ms, | |
| **extra_metrics, | |
| ) | |
| clear_request_context() | |
| def _start_temp_template_cleanup_thread() -> None: | |
| """Background thread that removes expired temporary templates every 30 minutes.""" | |
| from threading import Thread | |
| CLEANUP_INTERVAL = 30 * 60 # 30 minutes | |
| def _loop() -> None: | |
| while True: | |
| time.sleep(CLEANUP_INTERVAL) | |
| try: | |
| from exercises.push_up.template_registry import cleanup_expired_temporary_templates | |
| deleted = cleanup_expired_temporary_templates() | |
| if deleted: | |
| _log.info("temp_template_cleanup", deleted_count=len(deleted), deleted_ids=deleted) | |
| except Exception: | |
| _log.exception("temp_template_cleanup_error") | |
| Thread(target=_loop, daemon=True, name="temp-template-cleanup").start() | |
| def create_app() -> FastAPI: | |
| settings = get_settings() | |
| initialize_runtime(settings) | |
| is_prod = os.getenv("ENV", "dev").lower() == "production" | |
| api = FastAPI( | |
| title=settings.app_name, | |
| version=settings.api_version, | |
| description="FastAPI backend for AI Coach Gym video analysis.", | |
| docs_url=None if is_prod else "/docs", | |
| redoc_url=None if is_prod else "/redoc", | |
| openapi_url=None if is_prod else "/openapi.json", | |
| ) | |
| api.add_middleware(RequestContextMiddleware) | |
| api.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.cors_origins, | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| expose_headers=DIAGNOSTIC_RESPONSE_HEADERS, | |
| ) | |
| api.include_router(health.router) | |
| api.include_router(auth.router, prefix="/auth", tags=["auth"]) | |
| api.include_router(exercises.router, prefix="/exercises", tags=["exercises"]) | |
| api.include_router(templates.router, prefix="/templates", tags=["templates"]) | |
| api.include_router(analyses.router, prefix="/analyses", tags=["analyses"]) | |
| api.include_router(admin.router, prefix="/admin", tags=["admin"]) | |
| api.mount( | |
| settings.upload_url_prefix, | |
| StaticFiles(directory=str(settings.upload_dir)), | |
| name="uploads", | |
| ) | |
| _start_temp_template_cleanup_thread() | |
| return api | |
| app = create_app() | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("api.main:app", host="127.0.0.1", port=8000) | |