# app/core/exceptions.py """ Centralized exception hierarchy and global handlers for FastAPI. All API errors return a consistent JSON envelope: { "error": { "code": "VALIDATION_ERROR", "message": "Human-readable description", "details": { ... } # optional extra context } } """ import logging from typing import Any, Dict, Optional from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError logger = logging.getLogger("hale.exceptions") # ────────────────────────────────────────────── # Custom exception classes # ────────────────────────────────────────────── class HALEException(Exception): """Base exception for all HALE domain errors.""" status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR code: str = "INTERNAL_ERROR" def __init__(self, message: str, details: Optional[Dict[str, Any]] = None) -> None: self.message = message self.details = details or {} super().__init__(message) class NotFoundError(HALEException): status_code = status.HTTP_404_NOT_FOUND code = "NOT_FOUND" class ConflictError(HALEException): status_code = status.HTTP_409_CONFLICT code = "CONFLICT" class AuthenticationError(HALEException): status_code = status.HTTP_401_UNAUTHORIZED code = "AUTHENTICATION_ERROR" class AuthorizationError(HALEException): status_code = status.HTTP_403_FORBIDDEN code = "AUTHORIZATION_ERROR" class DomainValidationError(HALEException): status_code = status.HTTP_422_UNPROCESSABLE_CONTENT code = "VALIDATION_ERROR" class RateLimitError(HALEException): status_code = status.HTTP_429_TOO_MANY_REQUESTS code = "RATE_LIMITED" class ServiceUnavailableError(HALEException): status_code = status.HTTP_503_SERVICE_UNAVAILABLE code = "SERVICE_UNAVAILABLE" class AccountLockedError(HALEException): status_code = status.HTTP_423_LOCKED code = "ACCOUNT_LOCKED" # ────────────────────────────────────────────── # Response factory # ────────────────────────────────────────────── def _error_response( status_code: int, code: str, message: str, details: Optional[Dict[str, Any]] = None, request_id: Optional[str] = None, ) -> JSONResponse: body: Dict[str, Any] = { "error": { "code": code, "message": message, "details": details or {}, } } if request_id: body["request_id"] = request_id return JSONResponse(status_code=status_code, content=body) def _get_request_id(request: Request) -> Optional[str]: return getattr(request.state, "request_id", None) # ────────────────────────────────────────────── # Exception handlers # ────────────────────────────────────────────── async def hale_exception_handler(request: Request, exc: HALEException) -> JSONResponse: """Handler for all custom HALE domain exceptions.""" logger.warning( "Domain error [%s] %s: %s | path=%s", exc.code, exc.status_code, exc.message, request.url.path, ) return _error_response( status_code=exc.status_code, code=exc.code, message=exc.message, details=exc.details, request_id=_get_request_id(request), ) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """Handler for Pydantic request validation errors (422).""" field_errors: Dict[str, str] = {} for err in exc.errors(): loc = " → ".join(str(x) for x in err["loc"] if x != "body") field_errors[loc or "body"] = err["msg"] logger.info( "Validation error on %s: %s", request.url.path, field_errors, ) return _error_response( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, code="VALIDATION_ERROR", message="Request validation failed", details={"fields": field_errors}, request_id=_get_request_id(request), ) async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse: """Handler for database constraint violations.""" logger.warning("DB integrity error on %s: %s", request.url.path, str(exc.orig)[:200]) return _error_response( status_code=status.HTTP_409_CONFLICT, code="CONFLICT", message="A resource with these values already exists", request_id=_get_request_id(request), ) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Catch-all handler — logs full traceback, returns generic 500.""" logger.exception( "Unhandled exception on %s: %s", request.url.path, type(exc).__name__, ) return _error_response( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, code="INTERNAL_ERROR", message="An unexpected error occurred. Please try again later.", request_id=_get_request_id(request), ) # ────────────────────────────────────────────── # Registration helper # ────────────────────────────────────────────── def register_exception_handlers(app: FastAPI) -> None: """Register all exception handlers on the FastAPI app.""" app.add_exception_handler(HALEException, hale_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(IntegrityError, integrity_error_handler) app.add_exception_handler(Exception, global_exception_handler)