Spaces:
Sleeping
Sleeping
| # 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) | |