102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
|
|
"""
|
||
|
|
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}
|