152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
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,
|
|
)
|