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.
The Shape
Section titled “The Shape”{ "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.
What the Handler Emits
Section titled “What the Handler Emits”The code comes from the class of the exception, the message from the status.
| HTTP | error | Raised as |
|---|---|---|
| 400 | VALIDATION_ERROR | DRF ValidationError; details carries one entry per field message |
| 400 | INVALID_REQUEST | DRF ParseError: malformed JSON, unparseable body |
| 401 | AUTHENTICATION_REQUIRED | NotAuthenticated, AuthenticationFailed |
| 403 | PERMISSION_DENIED | PermissionDenied (DRF’s or Django’s) |
| 404 | NOT_FOUND | DRF NotFound |
| 405 | INVALID_REQUEST | MethodNotAllowed; the message is HTTP method not allowed. |
| 409 | the exception’s own code, or CONFLICT | an APIException subclass with status_code = 409 |
| 429 | RATE_LIMITED | Throttled, raised by a throttle class |
| 500 | INTERNAL_ERROR | anything the view did not catch |
Consequences of the lookup by class name:
- Raise DRF’s
NotFound, not Django’sHttp404.get_object_or_404raisesHttp404, which keeps status 404 but is reported asINVALID_REQUEST, because the code is looked up by class name. CatchDoesNotExistand raiseNotFound("Notice not found."). - An expired or invalid JWT answers 401 with
INVALID_REQUEST. simplejwt raisesInvalidToken, 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 asINVALID_REQUESTwith its own status. - Field issues are
INVALIDunless theValidationErrorwas raised as a field-to-list dict with its own DRF error codes; the text is indescription.
Pydantic Validation in Views
Section titled “Pydantic Validation in Views”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_drffrom 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.
Conflicts
Section titled “Conflicts”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.
Unhandled and Upstream Errors
Section titled “Unhandled and Upstream Errors”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
DoesNotExistandValueError; the view turns them intoNotFoundandValidationError. 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.
Declaring Errors in the Schema
Section titled “Declaring Errors in the Schema”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): ...Wiring the Handler in a Service
Section titled “Wiring the Handler in a Service”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_handlerfrom 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