""" A single, predictable error envelope for the whole API. Every failure - validation, permission, throttling, or an unexpected crash - comes back in the same shape: {"error": {"code": "validation_error", "message": "...", "details": {...}, "request_id": "..."}} Unhandled exceptions are logged with their stack trace but never leak internals to the client. """ from __future__ import annotations import logging from typing import Any from django.core.exceptions import PermissionDenied from django.http import Http404 from rest_framework import exceptions, status from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler from apps.core.logging import get_request_id logger = logging.getLogger("apps.errors") _CODE_BY_STATUS = { status.HTTP_400_BAD_REQUEST: "validation_error", status.HTTP_401_UNAUTHORIZED: "authentication_failed", status.HTTP_403_FORBIDDEN: "permission_denied", status.HTTP_404_NOT_FOUND: "not_found", status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed", status.HTTP_429_TOO_MANY_REQUESTS: "throttled", status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable", } class ServiceUnavailable(exceptions.APIException): status_code = status.HTTP_503_SERVICE_UNAVAILABLE default_detail = "A downstream dependency is unavailable. Please retry shortly." default_code = "service_unavailable" class SearchBackendError(ServiceUnavailable): default_detail = "The search backend is currently unavailable." default_code = "search_unavailable" def api_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None: response = drf_exception_handler(exc, context) if response is None: # Anything DRF does not know about is a bug on our side. if isinstance(exc, Http404): response = Response(status=status.HTTP_404_NOT_FOUND) elif isinstance(exc, PermissionDenied): response = Response(status=status.HTTP_403_FORBIDDEN) else: logger.exception( "unhandled_exception", extra={"event": "unhandled_exception", "view": str(context.get("view"))}, ) return Response( _envelope("internal_error", "An unexpected error occurred.", None), status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) detail = getattr(exc, "detail", None) if isinstance(exc, exceptions.ValidationError): # DRF labels every field error "invalid"; one stable code per failure # mode is far easier for a client to branch on. code = "validation_error" else: code = getattr(exc, "default_code", None) or _CODE_BY_STATUS.get( response.status_code, "error" ) if isinstance(detail, dict): message = "The request payload failed validation." details: Any = detail elif isinstance(detail, list): message = str(detail[0]) if detail else "Request failed." details = detail elif detail is not None: message = str(detail) details = None else: message = response.status_text details = None response.data = _envelope(code, message, details) return response def _envelope(code: str, message: str, details: Any) -> dict[str, Any]: error: dict[str, Any] = {"code": code, "message": message, "request_id": get_request_id()} if details: error["details"] = details return {"error": error}