from __future__ import annotations from typing import Any from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.exc import IntegrityError class ProblemResponse(BaseModel): code: str message: str details: dict[str, Any] = {} class DomainError(Exception): """Typed application error suitable for API responses.""" def __init__( self, message: str, *, code: str = "domain_error", status_code: int = 400, details: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.message = message self.code = code self.status_code = status_code self.details = details or {} class NotFoundError(DomainError): def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None: super().__init__(message, code="not_found", status_code=404, details=details) class ConflictError(DomainError): def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None: super().__init__(message, code="conflict", status_code=409, details=details) class ValidationError(DomainError): def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None: super().__init__(message, code="validation_error", status_code=422, details=details) class UnauthorizedError(DomainError): def __init__(self, message: str = "Not authenticated") -> None: super().__init__(message, code="unauthorized", status_code=401) class ForbiddenError(DomainError): def __init__(self, message: str = "Forbidden") -> None: super().__init__(message, code="forbidden", status_code=403) def register_error_handlers(app: FastAPI) -> None: @app.exception_handler(DomainError) async def domain_error_handler(_: Request, exc: DomainError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content={"code": exc.code, "message": exc.message, "details": exc.details}, ) @app.exception_handler(IntegrityError) async def integrity_error_handler(_: Request, exc: IntegrityError) -> JSONResponse: return JSONResponse( status_code=409, content={ "code": "integrity_error", "message": "The request conflicts with existing data or violates a database constraint.", "details": {"database_error": str(exc.orig)}, }, )