Files

102 lines
3.5 KiB
Python
Raw Permalink Normal View History

"""
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}