How would you write a custom EXCEPTION_HANDLER in Django REST Framework so a partner API returns one error envelope, and what will it still miss?
answer
- a function taking exc and context
- delegate to the default first
- return None to re-raise
- only raised exceptions reach it
- middleware and unmatched URLs
basics
~20 sWrite a function (exc, context) that calls rest_framework.views.exception_handler, reshapes response.data into your envelope, and set it as EXCEPTION_HANDLER. It never sees responses a view returns itself, errors outside DRF views, or unhandled exceptions it returns None for.
solid answer
~40 sSet `REST_FRAMEWORK['EXCEPTION_HANDLER']` to a function taking `(exc, context)`, where `context` holds `view`, `args`, `kwargs` and `request`. Map Django's `Http404` and `PermissionDenied` to DRF's classes first, then call the default `exception_handler`, which sets the status, the `Retry-After`/`WWW-Authenticate` headers and the transaction rollback. If it returns a `Response`, rewrite `response.data` into the envelope, for example `{"error": {"status", "code", "message", "fields"}}`, using `get_codes()` and `get_full_details()`. Return `None` for anything unknown so Django logs a 500. The handler misses what never raises inside `APIView.dispatch()`: a view returning `Response(serializer.errors, status=400)` itself, middleware errors, unmatched URLs, failures while rendering, and the 500 path, where Django's `handler500` (for example DRF's `server_error`) decides the body.
code
python · 23 linesfrom django.core.exceptions import PermissionDenied as DjangoPermissionDenied
from django.http import Http404
from rest_framework import exceptions
from rest_framework.views import exception_handler
def partner_exception_handler(exc, context):
if isinstance(exc, Http404):
exc = exceptions.NotFound(*exc.args)
elif isinstance(exc, DjangoPermissionDenied):
exc = exceptions.PermissionDenied(*exc.args)
response = exception_handler(exc, context)
if response is None:
return None
if isinstance(exc, exceptions.ValidationError):
error = {"code": "invalid", "message": "Invalid input.", "fields": exc.get_full_details()}
else:
error = {"code": exc.get_codes(), "message": str(exc.detail)}
error["status"] = response.status_code
response.data = {"error": error}
return responsego deeper
Know the EXCEPTION_HANDLER setting and that the usual pattern calls DRF's default handler and then edits response.data.
Explain the (exc, context) signature, the None contract, and why Django's Http404 should be converted before reshaping.
List what bypasses the handler, from hand-returned responses to middleware, URL resolution and rendering, and close each gap with tests.
Own the error contract across services: one envelope, a documented code catalogue, and a rule that new endpoints raise rather than return errors.
## Where the handler sits In Django REST Framework, `APIView.dispatch()` runs `initial()` and the handler method inside one `try`. Any exception goes to `handle_exception()`, which calls the function named by the `EXCEPTION_HANDLER` setting (default `rest_framework.views.exception_handler`) with two arguments: - `exc`: the exception instance; - `context`: a dict with `view`, `args`, `kwargs` and `request`. The function returns a `Response`, which DRF finalises and renders with the negotiated renderer, or `None`, in which case DRF re-raises and Django's 500 handling takes over. A single view can use a different handler by overriding `get_exception_handler()`. ## Building the partner envelope The partner API wants every error in one shape, whatever raised it: ```json {"error": {"status": 404, "code": "not_found", "message": "Job not found."}} ``` The robust recipe: 1. **Normalise Django exceptions first.** The default handler converts `Http404` and Django's `PermissionDenied` only into local variables, so your `exc` would still be the Django object without `get_codes()`. Convert them yourself before delegating. 2. **Delegate to the default handler.** It picks the status, adds `Retry-After` for `Throttled` and `WWW-Authenticate` for 401s, and calls `set_rollback()` so an `ATOMIC_REQUESTS` transaction is rolled back. 3. **Reshape `response.data`.** For `ValidationError`, put `exc.get_full_details()` under `fields`; for other `APIException`s, use `exc.get_codes()` and the message. 4. **Return `None` for unknown exceptions.** Turning every `Exception` into a 4xx hides bugs and returns client errors for server faults. ## What the handler never sees | Case | Why it bypasses the handler | Fix | |---|---|---| | `return Response(serializer.errors, status=400)` | nothing was raised | use `is_valid(raise_exception=True)`, as DRF's generic mixins do | | exception in middleware | outside `dispatch()` | handle in middleware or rely on Django's error views | | unmatched URL | Django's resolver raises before any DRF view | a JSON `handler404` in the root URLconf | | error while rendering | rendering happens after `dispatch()` returns | fix the renderer or data | | handler returned `None` | re-raised to Django | `handler500` such as `rest_framework.exceptions.server_error`, which returns `{"error": "Server Error (500)"}` | The DRF docs still say generic views return validation 400s directly; in current DRF the create and update mixins call `is_valid(raise_exception=True)`, so those errors do pass through the handler. Hand-written views that return errors are the real gap. ## Testing and operating it - Assert the envelope for at least 400, 401 or 403, 404, 405, 415 and 429, because each comes from a different raise site. - Keep `Retry-After` and `WWW-Authenticate`: reshaping `response.data` on the response the default handler built preserves its headers. - Log unexpected exceptions where they re-raise, not in the handler, so a 500 is not double-reported. - Document the codes. The value of the envelope is that partners branch on `code`, so codes are the part of the contract that must not drift.
- Why convert Http404 yourself before calling the default handler?The default `exception_handler` converts `Http404` into `NotFound` inside its own scope and never hands the converted object back. Your function still holds the Django `Http404`, which has no `get_codes()` or DRF `detail`. Converting first means both the default logic and your reshaping see the same DRF exception.
- How do you get a JSON body for a URL that matches no route?That 404 is raised by Django's URL resolver before any DRF view runs, so `EXCEPTION_HANDLER` never sees it. Set `handler404` in the root URLconf to a view that returns your envelope, and `handler500` for the unhandled path; DRF ships `rest_framework.exceptions.server_error` and `bad_request` as simple JSON versions.
The exception handler is a translator stationed at one department's door: every complaint raised inside that department leaves in the house language. Complaints made in the lobby, before anyone reaches the door, never pass the translator.
saying these in an interview costs you the question
- The custom handler also formats Response(serializer.errors, status=400) returned by a view
- Returning None from the handler makes DRF send an empty 500 JSON body
- EXCEPTION_HANDLER catches exceptions raised in middleware
- Wrapping every Exception in a 400 is a safe default for partner APIs
- Reshaping response.data drops the Retry-After header