skip to content

In Django Ninja, how do you register a custom exception handler, and how does NinjaAPI decide which handler handles a raised exception?

level: middleimportance: should knowfreq 40%

answer

  1. decorator on the API object
  2. handler takes request and exc
  3. walk the exception's MRO
  4. four handlers installed by default

basics

~10 s

Decorate a function with @api.exception_handler(SomeError) on the NinjaAPI instance; it takes request and exc and returns a response. NinjaAPI walks the exception's MRO and uses the first class with a registered handler.

solid answer

~40 s

Handlers live on the `NinjaAPI` instance, not on routers: `@api.exception_handler(RoomUnavailable)` or `api.add_exception_handler(...)`. A handler receives `(request, exc)` and must return an `HttpResponse`, usually `api.create_response(request, data, status=409)` so the API's renderer is used. When an exception escapes an operation, `NinjaAPI.on_exception()` walks `type(exc).__mro__` and calls the handler of the **first class** that has one, so the most specific registration wins regardless of order. Every API starts with handlers for `Exception`, `Http404`, `HttpError` and `ninja.errors.ValidationError`; `AuthenticationError`, `AuthorizationError` and `Throttled` are `HttpError` subclasses, so they share its `{"detail": message}` rendering unless you register something more specific. The default `Exception` handler returns a plain-text traceback when `DEBUG` is on and otherwise re-raises to Django.

code

python · 24 lines
python
from ninja import NinjaAPI
from ninja.errors import AuthenticationError

api = NinjaAPI()


class RoomUnavailable(Exception):
    def __init__(self, room_id):
        self.room_id = room_id


@api.exception_handler(RoomUnavailable)
def room_unavailable(request, exc):
    return api.create_response(
        request,
        {"code": "room_unavailable", "room_id": exc.room_id},
        status=409,
    )


@api.exception_handler(AuthenticationError)
def unauthenticated(request, exc):
    # More specific than the default HttpError handler, so it wins for 401s.
    return api.create_response(request, {"code": "login_required"}, status=401)

go deeper

for a junior

Recall the decorator on the NinjaAPI instance and that a handler takes request and exc and returns a response.

for a middle

Explain the MRO walk, the four default handlers, and why auth and throttle errors come out as detail bodies.

for a senior

Design domain-exception mapping for an API, and know what replacing the Exception handler costs in logging and DEBUG tracebacks.

for a principal

Decide how error responses stay uniform across several NinjaAPI instances and teams, since handlers are per API rather than global.

## Registering a handler In **Django Ninja**, exception handlers belong to the **`NinjaAPI` instance**. There are two equivalent spellings: - the decorator `@api.exception_handler(ExcClass)` on a function; - the call `api.add_exception_handler(ExcClass, func)`. The handler takes two arguments, the Django `request` and the exception `exc`, and **must return an HTTP response**. The idiomatic return is `api.create_response(request, data, status=...)`, which renders `data` with the API's configured renderer, so error bodies match the rest of the API. A `Router` has no handler registry; everything registered applies to every operation mounted under that `NinjaAPI`. Two API instances - a `v1` and a `v2` - keep separate registries. ## The handlers every API starts with | registered class | default response | |---|---| | `ninja.errors.ValidationError` | 422, `{"detail": [...]}` | | `ninja.errors.HttpError` | the exception's status, `{"detail": message}` | | `django.http.Http404` | 404, `{"detail": "Not Found"}` (with the exception text under `DEBUG`) | | `Exception` | `DEBUG` on: 500 with a plain-text traceback; `DEBUG` off: re-raises | `AuthenticationError` (401), `AuthorizationError` (403) and `Throttled` (429) all subclass `HttpError`, which is why they come out as `{"detail": ...}` with their own codes. When the `Exception` handler re-raises, Django's normal 500 handling takes over, with its logging and error views. ## How the handler is chosen When an exception escapes an operation - from parameter validation, the view, or response serialisation - Ninja calls `NinjaAPI.on_exception(request, exc)`: 1. It walks `type(exc).__mro__`, from the exception's own class towards `object`. 2. At each class it checks the registry; the **first hit** is the handler. 3. If nothing matches (only possible if the `Exception` default was removed), the exception is re-raised. Because the walk follows inheritance, **registration order does not matter**: a handler for `AuthenticationError` beats the `HttpError` handler for that exception whether it was added before or after. Registering a class that is already registered replaces the old handler; that is how you override a default. ## Patterns for a booking API - **Domain exceptions** - raise `RoomUnavailable` in the service layer and map it once to 409 with `@api.exception_handler(RoomUnavailable)`, instead of catching it in every view. - **One-off statuses** - `raise HttpError(409, "Room already booked")` from `ninja.errors` anywhere in the call stack; the default handler renders it. - **Reshaping validation errors** - override the `ValidationError` handler, or subclass `NinjaAPI` and override `validation_error_from_error_contexts()` to change the error list while keeping the 422 handler. - **A JSON 500** - register your own `Exception` handler, log the exception there, and return a generic body; you then own logging that Django would otherwise do. ## Handlers and the OpenAPI document Handlers produce responses outside the operation's `response=` mapping, so the generated OpenAPI document does not learn about them. If `RoomUnavailable` becomes a 409 through a handler, clients reading the docs see no 409 unless the operation also declares one, for example `response={201: BookingOut, 409: ErrorOut}`. Two consistent conventions exist: - **declare and return** - list every error code per operation and return `Status(409, ...)`, so validation and docs both cover the body; - **raise and handle** - raise domain exceptions, map them centrally in handlers, and declare the shared error schema on each operation so the docs match. The second keeps views short; the first keeps each operation self-describing. Either works when applied everywhere; mixing them is how one API ends up with two different 409 bodies. ## What handlers do not see The registry only covers exceptions raised while an **operation** runs. Errors from Django middleware, URL resolution outside the API, or code that runs after the response is returned never reach it. Replacing the `Exception` handler also removes the `DEBUG` traceback convenience, so keep that behaviour if developers rely on it. ## Mistakes interviewers probe - Looking for `@router.exception_handler` - there is none. - Returning a dict from a handler instead of a response. - Assuming the first-registered handler wins over a more specific one. - Expecting a handler for `pydantic.ValidationError` to intercept bad request bodies, which raise `ninja.errors.ValidationError`.

  • With DEBUG off, what happens to an unexpected exception in a Django Ninja view?
    The default `Exception` handler re-raises it, so Django's own error handling runs: the exception is logged through Django's request logging and the configured 500 view renders the response. With `DEBUG` on, Ninja instead logs it and returns the traceback as a text/plain 500.
  • Does registering a handler on api affect operations added through a Router?
    Yes. Routers are mounted onto a `NinjaAPI` with `add_router()`, and every operation's exceptions go to that API's `on_exception()`.

saying these in an interview costs you the question

  • Each Router needs its own @router.exception_handler registration.
  • The handler registered first wins, whatever the exception's class.
  • An exception handler can return a plain dict and Ninja renders it.
  • A handler for pydantic.ValidationError reshapes bad request bodies.
  • With DEBUG off, Ninja's default handler returns a JSON 500 body.