from __future__ import annotations from typing import Any from uuid import uuid4 from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError class ProblemDetails(BaseModel): """RFC 9457 compatible error response used by the REST contract.""" type: str title: str status: int detail: str instance: str code: str trace_id: str errors: list[dict[str, Any]] = Field(default_factory=list) retryable: bool = False related_refs: list[Any] = Field(default_factory=list) recovery_action: str = "none" retry_after_seconds: int | None = None def _trace_id(request: Request) -> str: return request.headers.get("X-Request-Id") or str(uuid4()) def _problem_response( request: Request, *, status_code: int, title: str, detail: str, code: str, errors: list[dict[str, Any]] | None = None, retryable: bool = False, related_refs: list[Any] | None = None, recovery_action: str = "none", retry_after_seconds: int | None = None, ) -> JSONResponse: problem = ProblemDetails( type=f"https://tjwater.example/problems/{code.replace('_', '-')}", title=title, status=status_code, detail=detail, instance=request.url.path, code=code, trace_id=_trace_id(request), errors=errors or [], retryable=retryable, related_refs=related_refs or [], recovery_action=recovery_action, retry_after_seconds=retry_after_seconds, ) return JSONResponse( status_code=status_code, content=problem.model_dump(mode="json"), media_type="application/problem+json", ) def install_problem_details_handlers(app: FastAPI) -> None: @app.exception_handler(MaterializedViewRefreshAfterCommitError) async def materialized_view_refresh_error_handler( request: Request, exc: MaterializedViewRefreshAfterCommitError, ) -> JSONResponse: response = _problem_response( request, status_code=503, title="Materialized view refresh failed", detail=( f"Project {exc.project!r} changes were committed, but GIS query " "views could not be refreshed. Do not repeat the write blindly." ), code="materialized_view_refresh_failed_after_commit", ) response.headers["X-TJWater-Changes-Committed"] = "true" return response @app.exception_handler(RequestValidationError) async def validation_error_handler( request: Request, exc: RequestValidationError, ) -> JSONResponse: return _problem_response( request, status_code=422, title="Validation error", detail="Request validation failed", code="validation_error", errors=exc.errors(), ) @app.exception_handler(HTTPException) async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse: code_by_status = { 401: "unauthenticated", 403: "forbidden", 404: "not_found", 409: "conflict", 422: "validation_error", 503: "dependency_unavailable", } fallback_code = code_by_status.get(exc.status_code, "request_error") detail_payload = exc.detail if isinstance(exc.detail, dict) else None code = ( detail_payload.get("code") if detail_payload and isinstance(detail_payload.get("code"), str) else fallback_code ) detail = exc.detail if isinstance(exc.detail, str) else None if detail_payload: candidate = detail_payload.get("message") or detail_payload.get("detail") if isinstance(candidate, str): detail = candidate if detail is None: detail = code.replace("_", " ").title() related_refs = detail_payload.get("related_refs", []) if detail_payload else [] if not isinstance(related_refs, list): related_refs = [] recovery_action = ( detail_payload.get("recovery_action", "none") if detail_payload else "none" ) if not isinstance(recovery_action, str): recovery_action = "none" retry_after_seconds = ( detail_payload.get("retry_after_seconds") if detail_payload else None ) if not isinstance(retry_after_seconds, int): retry_after_seconds = None return _problem_response( request, status_code=exc.status_code, title=fallback_code.replace("_", " ").title(), detail=detail, code=code, retryable=( bool(detail_payload.get("retryable", False)) if detail_payload else False ), related_refs=related_refs, recovery_action=recovery_action, retry_after_seconds=retry_after_seconds, )