Skip to content

Error Handling

Every error on a v2 path leaves through one exception handler and has one shape. The handler lives in django_utils/api/v2_errors.py (package entirius-django-utils). A view raises a DRF exception; it does not build an error body.

{
"error": "VALIDATION_ERROR",
"message": "Request validation failed.",
"debug_id": "f7a2c3b8",
"details": [
{
"field": "sku",
"location": "body",
"issue": "INVALID",
"description": "Field required"
}
]
}

error is the machine-readable code, message a fixed sentence per status (a conflict carries its own), debug_id a random id that is also written to the log line, details the per-field list (the handler fills it for validation errors and conflicts). The Pydantic models are ErrorResponse and ErrorDetail in the same file. What a client does with the envelope: API Overview.

The code comes from the class of the exception, the message from the status.

HTTPerrorRaised as
400VALIDATION_ERRORDRF ValidationError; details carries one entry per field message
400INVALID_REQUESTDRF ParseError: malformed JSON, unparseable body
401AUTHENTICATION_REQUIREDNotAuthenticated, AuthenticationFailed
403PERMISSION_DENIEDPermissionDenied (DRF’s or Django’s)
404NOT_FOUNDDRF NotFound
405INVALID_REQUESTMethodNotAllowed; the message is HTTP method not allowed.
409the exception’s own code, or CONFLICTan APIException subclass with status_code = 409
429RATE_LIMITEDThrottled, raised by a throttle class
500INTERNAL_ERRORanything the view did not catch

Consequences of the lookup by class name:

  • Raise DRF’s NotFound, not Django’s Http404. get_object_or_404 raises Http404, which keeps status 404 but is reported as INVALID_REQUEST, because the code is looked up by class name. Catch DoesNotExist and raise NotFound("Notice not found.").
  • An expired or invalid JWT answers 401 with INVALID_REQUEST. simplejwt raises InvalidToken, a subclass the map does not name. A client decides on the status, not on the code.
  • Any other DRF exception (406, 415, a custom APIException, a subclass of a mapped one) is reported as INVALID_REQUEST with its own status.
  • Field issues are INVALID unless the ValidationError was raised as a field-to-list dict with its own DRF error codes; the text is in description.

A view that parses the request with Pydantic re-raises the ValidationError as a DRF one, so the handler formats it:

from django_utils.api.v2_errors import raise_pydantic_as_drf
from pydantic import ValidationError
def create(self, request, **kwargs):
try:
data = NoticeCreateRequest(**request.data)
except ValidationError as exc:
raise_pydantic_as_drf(exc)
...

raise_pydantic_as_drf() turns the Pydantic errors into a field-to-messages dict; a nested field becomes a dotted path (items.0.sku). The CMS reads details[] to show the message under the field.

Never return the Pydantic error from the view. Response({"detail": exc.errors()}, status=400) and Response({"detail": str(exc)}, status=400) both bypass the handler: the client gets a body it cannot parse, and exc.errors() exposes validator names and internal field paths.

A state conflict is an APIException subclass with status 409, declared next to the views that raise it:

from rest_framework.exceptions import APIException
class Conflict(APIException):
status_code = 409
default_detail = "The request conflicts with the current state."
default_code = "conflict"

The handler keeps what the exception carries. Conflict("message already reviewed", code="already_reviewed") answers error: ALREADY_REVIEWED with that message; Conflict() answers error: CONFLICT with the default detail; Conflict({"domain": ["already exists"]}) answers CONFLICT with the field in details[]. The message of a conflict is text written for the client, never an exception from a lower layer.

Let an unexpected exception propagate. The handler logs it with the traceback and the debug_id and answers:

{
"error": "INTERNAL_ERROR",
"message": "An internal error occurred.",
"debug_id": "3c1f9a02",
"details": []
}

The client quotes the debug_id; the log line with the same id holds the cause. A view needs no except Exception for this, and a private helper that returns {"detail": "..."} for a 500 produces a second error shape. Some modules still carry one from before the shared handler (django_pim/api/admin/errors.py is an example); new code does not add or call them.

Rules for the except blocks a view does have:

  • Catch specific exceptions, by type. Services raise DoesNotExist and ValueError; the view turns them into NotFound and ValidationError.
  • str(exc) reaches a response only for a 400 or a 404 whose text the module wrote itself (ValueError("Slot is outside business hours")).
  • The text of an upstream exception (a payment SDK error, a calendar provider error, a mail backend timeout) never reaches a response. Such messages carry tokens in headers, ids in URLs and file paths. Log the exception, and answer with the envelope and a message written for the client.

Each action lists the error statuses it can answer in @extend_schema, next to the success response (OpenAPI Generation):

@extend_schema(
summary="List products",
responses={
200: ProductListResponse,
401: OpenApiResponse(description="Authentication required"),
403: OpenApiResponse(description="Permission denied"),
},
)
def list(self, request, channel_idx=None, **kwargs): ...

make_exception_handler() returns a DRF exception handler that formats errors on /api/.../v2/... paths and passes every other path on. The Volkanos service wires it in main/v2_errors.py:

from django_contentdb.utils import standard_exception_handler
from django_utils.api.v2_errors import make_exception_handler
volkanos_exception_handler = make_exception_handler(v1_handler=standard_exception_handler)

and points DRF at it in main/settings.py:

REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"EXCEPTION_HANDLER": "main.v2_errors.volkanos_exception_handler",
}

v1_handler serves the paths without /v2/; without it DRF’s own handler does. DRF resolves EXCEPTION_HANDLER at the first error, not at startup: an import that fails in main/v2_errors.py turns every error response into a bare 500, so a handler from an optional module is imported defensively.

A module’s test settings point at the v2 handler directly, or its tests see DRF’s default error body instead of the envelope:

REST_FRAMEWORK = {"EXCEPTION_HANDLER": "django_utils.api.v2_errors.v2_exception_handler"}

OpenAPI Generation: how these responses surface in the spec