In Django Ninja, how do you register a custom exception handler, and how does NinjaAPI decide which handler handles a raised exception?
answer
- decorator on the API object
- handler takes request and exc
- walk the exception's MRO
- four handlers installed by default
basics
~10 sDecorate 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 sHandlers 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 linesfrom 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
Recall the decorator on the NinjaAPI instance and that a handler takes request and exc and returns a response.
Explain the MRO walk, the four default handlers, and why auth and throttle errors come out as detail bodies.
Design domain-exception mapping for an API, and know what replacing the Exception handler costs in logging and DEBUG tracebacks.
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.