hale-api / app /core /exceptions.py
Raunak211006's picture
Deploy HALE API v3.1
cc38116 verified
Raw
History Blame Contribute Delete
6.46 kB
# 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)