108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
"""
|
|
Global Error Handling Middleware
|
|
Catches all unhandled exceptions and standardizes API error responses.
|
|
"""
|
|
|
|
import logging
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response, JSONResponse
|
|
from fastapi import HTTPException
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
# Import APIKeyError for handling
|
|
from middleware.api_key_auth import APIKeyError
|
|
|
|
try:
|
|
import structlog
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
except ImportError:
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def format_error_response(
|
|
status_code: int,
|
|
message: str,
|
|
error_code: str = None,
|
|
details: dict = None,
|
|
request_id: str = "unknown",
|
|
headers: dict = None,
|
|
) -> JSONResponse:
|
|
"""
|
|
Standardizes the error response format.
|
|
Format: {error: "CODE", message: "...", details: {...}}
|
|
"""
|
|
if not error_code:
|
|
error_code = _map_http_status_to_code(status_code)
|
|
|
|
content = {"error": error_code, "message": message, "details": details or {}}
|
|
|
|
# Always include request_id in details if not present
|
|
if "request_id" not in content["details"]:
|
|
content["details"]["request_id"] = request_id
|
|
|
|
return JSONResponse(status_code=status_code, content=content, headers=headers)
|
|
|
|
|
|
def _map_http_status_to_code(status_code: int) -> str:
|
|
"""Map HTTP status codes to architectural error codes."""
|
|
mapping = {
|
|
400: "INVALID_FORMAT",
|
|
401: "UNAUTHORIZED",
|
|
403: "FORBIDDEN",
|
|
404: "NOT_FOUND",
|
|
405: "METHOD_NOT_ALLOWED",
|
|
413: "FILE_TOO_LARGE",
|
|
422: "VALIDATION_ERROR",
|
|
429: "QUOTA_EXCEEDED",
|
|
502: "PROVIDER_ERROR",
|
|
503: "SERVICE_UNAVAILABLE",
|
|
}
|
|
return mapping.get(status_code, "INTERNAL_ERROR")
|
|
|
|
|
|
class ErrorHandlingMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Catch all unhandled exceptions (Exception) that bubble up to the top.
|
|
Note: HTTPException is often caught by FastAPI handlers before reaching here.
|
|
"""
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
try:
|
|
return await call_next(request)
|
|
except APIKeyError as e:
|
|
# Handle APIKeyError with structured response using to_response()
|
|
request_id = getattr(request.state, "request_id", "unknown")
|
|
logger.info(f"[{request_id}] API Key authentication error: {e.code}")
|
|
return e.to_response()
|
|
except Exception as e:
|
|
request_id = getattr(request.state, "request_id", "unknown")
|
|
|
|
# If it's already an HTTPException, we might want to handle it specifically if it leaked through
|
|
if isinstance(e, (HTTPException, StarletteHTTPException)):
|
|
detail = e.detail if hasattr(e, "detail") and e.detail else {}
|
|
if isinstance(detail, dict):
|
|
return format_error_response(
|
|
status_code=e.status_code,
|
|
message=detail.get("message", "Une erreur s'est produite."),
|
|
error_code=detail.get("error"),
|
|
request_id=request_id,
|
|
)
|
|
return format_error_response(
|
|
status_code=e.status_code,
|
|
message=str(detail) if detail else "Une erreur s'est produite.",
|
|
request_id=request_id,
|
|
)
|
|
|
|
# Log the full stack trace for internal debugging
|
|
logger.exception(f"[{request_id}] Unhandled internal exception: {str(e)}")
|
|
|
|
# Return generic error in French to user (AC4, AC5)
|
|
return format_error_response(
|
|
status_code=500,
|
|
message="Une erreur inattendue s'est produite. Veuillez réessayer plus tard.",
|
|
error_code="INTERNAL_ERROR",
|
|
request_id=request_id,
|
|
)
|