Error handling

django-modern-rest has 3 layers where errors might be handled. It provides flexible error handling logic on Endpoint, Controller, and global levels.

All error handling functions always accept 3 arguments:

  1. Endpoint where error happened

  2. Controller where error happened

  3. Exception that happened

Here’s how it works:

  1. We first try to call error_handler that was passed into the endpoint definition via modify() or validate()

  2. If it returns django.http.HttpResponse, return it to the user

  3. If it raises, call handle_error() for sync controllers and handle_async_error() for async controllers

  4. If controller’s handler returns HttpResponse, return it to the user

  5. If it raises, call configured global error handler, by default it is global_error_handler() (it is always sync)

Warning

There are two things to keep in mind:

  1. Async endpoints will require async error_handler parameter, Sync endpoints will require sync error_handler parameter. This is validated on endpoint creation

  2. We don’t allow to define sync handle_error handlers for async controllers. We also don’t allow async handle_async_error handlers for sync controllers.

Note

APIError does not follow any of these rules and has a default handler, which will convert an instance of APIError to HttpResponse via to_error() call.

You don’t need to catch APIError in any way, unless you know what you are doing.

Customizing endpoint error handler

Let’s pass custom error handling to a single endpoint:

Run result

$ curl http://127.0.0.1:8000/api/math/ -D - -X PATCH -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
HTTP/1.1 400 Bad Request
date: Sun, 29 Mar 2026 18:52:46 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 39
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"division by zero"}]}

$ curl http://127.0.0.1:8000/api/math/ -D - -X POST -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
HTTP/1.1 500 Internal Server Error
date: Sun, 29 Mar 2026 18:52:46 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 68
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"Internal server error","type":"internal_error"}]}

In this example we add error handling defined as division_error to patch endpoint (which serves as a division operation), while keeping post endpoint (which serves as a multiply operation) without a custom error handler. Because ZeroDivisionError can’t happen in post.

Per-endpoint’s error handling has a priority over per-controller and global handlers.

You can also define endpoint error handlers as controller methods and pass them wrapped with wrap_handler() as handlers. Like so:

Run result

$ curl http://127.0.0.1:8000/api/math/ -D - -X PATCH -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
HTTP/1.1 400 Bad Request
date: Sun, 29 Mar 2026 18:52:46 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 39
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"division by zero"}]}

Customizing controller error handler

Let’s create custom error handling for the whole controller:

Going further

Now you can understand how you can create:

  • Endpoints with custom error handlers

  • Controllers with custom error handlers

  • ResponseSpec objects for new error response schemas

You can dive even deeper and:

Error handling diagram

The same error handling logic can be represented as a diagram:

        ---
config:
  theme: forest

---
graph TB
    Start[Request] --> Error{Error?};
    Error -->|Yes| Endpoint[Endpoint-level handler];
    Endpoint --> EndpointHandler{Raises or returns response?};
    EndpointHandler -->|response| Failure[Error response];
    EndpointHandler -->|raises| Controller[Controller-level handler];
    Controller --> ControllerHandler{Raises or returns response?};
    ControllerHandler -->|response| Failure[Error response];
    ControllerHandler -->|raises| Global[Global handler];
    Global --> GlobalHandler{Raises or returns response?};
    GlobalHandler -->|response| Failure[Error response];
    GlobalHandler -->|raises| Reraises[Reraises error];
    Error ---->|No| Success[Successful response];
    

Error handling logic

Customizing error messages

All error messages, including pre-defined ones, can be easily customized on a per-controller basis.

To do so, you would need to change:

  1. error_model attribute for all controllers that will be using this error message schema

  2. format_error() method to provide custom runtime error formatting

Run result

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json'
{"errors":[{"message":"test msg"}]}

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '[]' -H 'Content-Type: application/json'
{"errors":[{"message":"Input should be a valid dictionary"}]}

OpenAPI Schema

Preview openapi.json
{
  "components": {
    "schemas": {
      "CustomErrorDetail": {
        "properties": {
          "message": {
            "title": "Message",
            "type": "string"
          }
        },
        "required": [
          "message"
        ],
        "title": "CustomErrorDetail",
        "type": "object"
      },
      "CustomErrorModel": {
        "properties": {
          "errors": {
            "items": {
              "$ref": "#/components/schemas/CustomErrorDetail"
            },
            "title": "Errors",
            "type": "array"
          }
        },
        "required": [
          "errors"
        ],
        "title": "CustomErrorModel",
        "type": "object"
      }
    },
    "securitySchemes": {}
  },
  "info": {
    "title": "Django Modern Rest",
    "version": "0.1.0"
  },
  "openapi": "3.2.0",
  "paths": {
    "/api/apicontroller/": {
      "post": {
        "deprecated": false,
        "operationId": "postApicontrollerApiApicontroller",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": {
                  "type": "string"
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Created"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomErrorModel"
                }
              }
            },
            "description": "Raised when request components cannot be parsed"
          },
          "402": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomErrorModel"
                }
              }
            },
            "description": "Payment Required"
          },
          "406": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomErrorModel"
                }
              }
            },
            "description": "Raised when provided `Accept` header cannot be satisfied"
          },
          "422": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomErrorModel"
                }
              }
            },
            "description": "Raised when returned response does not match the response schema"
          }
        }
      }
    }
  }
}

This will also change the OpenAPI schema for the affected controller.

See ErrorModel for the default error model schema. And format_error() for the default error formatting.

See content negotiation docs about how to use different error models for different content types.

Handling validation errors from models

When creating models with, for example , pydantic.BaseModel, your validation can fail. This error will not be handled by design.

Why? Because catching all specific validation errors for a specific serializer that can happen in your application will do more harm than good.

This is the default behavior:

Run result

$ curl http://127.0.0.1:8000/api/ping/ -D - -X GET
HTTP/1.1 500 Internal Server Error
date: Sun, 29 Mar 2026 18:52:48 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 68
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"Internal server error","type":"internal_error"}]}

If you want to catch this error in a specific place and attach a specific behavior, use an error handler at a proper level.

For example, here we would handle it on a controller level:

Run result

$ curl http://127.0.0.1:8000/api/ping/ -D - -X GET
HTTP/1.1 500 Internal Server Error
date: Sun, 29 Mar 2026 18:52:48 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 39
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"Validation error"}]}

Now, the error is handled: we modified its error text and status code. Remember not to dump all the error information out to users, since they might contain sensitive data.

See also

See Handling 500 errors if you want to change the 500 error rendering.

API Reference

dmr.errors.global_error_handler(endpoint: Endpoint, controller: Controller[BaseSerializer], exc: Exception) HttpResponse[source]

Global error handler for all cases.

It is the last item in the chain that we try:

  1. Per endpoint configuration via handle_error() and handle_async_error() methods

  2. Per controller handlers

  3. This global handler, specified via the configuration

If some exception cannot be handled, it is just reraised.

Parameters:
  • endpoint – Endpoint where error happened.

  • controller – Controller instance that endpoint belongs to.

  • exc – Exception instance that happened.

Returns:

HttpResponse with proper response for this error. Or raise exc back.

Here’s an example that will produce {'detail': [{'msg': 'inf', 'type': 'user_msg'}]} for any ZeroDivisionError in your application:

>>> from http import HTTPStatus
>>> from django.http import HttpResponse
>>> from dmr.controller import Controller
>>> from dmr.endpoint import Endpoint
>>> from dmr.errors import global_error_handler, ErrorType

>>> def custom_error_handler(
...     controller: Controller,
...     endpoint: Endpoint,
...     exc: Exception,
... ) -> HttpResponse:
...     if isinstance(exc, ZeroDivisionError):
...         return controller.to_error(
...             controller.format_error(
...                 'inf',
...                 error_type=ErrorType.user_msg,
...             ),
...             status_code=HTTPStatus.NOT_IMPLEMENTED,
...         )
...     # Call the original handler to handle default errors:
...     return global_error_handler(controller, endpoint, exc)

>>> # And then in your settings file:
>>> DMR_SETTINGS = {
...     # Object `custom_error_handler` will also work:
...     'global_error_handler': 'path.to.custom_error_handler',
... }

Warning

Make sure you always call original global_error_handler in the very end. Unless, you want to disable original error handling.

dmr.errors.wrap_handler(method: _MethodSyncHandler) SyncErrorHandler[source]
dmr.errors.wrap_handler(method: _MethodAsyncHandler) AsyncErrorHandler

Utility function to wrap controller methods.

It is used to wrap an existing controller method and pass it as error_handler= argument to an endpoint.

final class dmr.errors.ErrorType(*values)[source]

Collection of all possible error types that we use in DMR.

value_error

Raised when we can’t parse something.

internal_error

Raised when internal error happens.

not_allowed

Raised when using unsupported http method. 405 alias.

security

Raised when security related error happens.

user_msg

Raised for custom errors from users.

not_found

Raised when we can’t find controller.

streaming

Happens when we stream events.

class dmr.errors.ErrorModel[source]

Default error response schema.

Can be customized. See Customizing error messages for more details.

class dmr.errors.ErrorDetail[source]

Base schema for error details description.

dmr.errors.format_error(error: str | Exception, *, loc: str | list[str | int] | None = None, error_type: str | ErrorType | None = None) ErrorModel[source]

Convert error to the common format.

Default implementation.

Parameters:
  • error – A serialization exception like a validation error.

  • loc – Location where this error happened. Like "headers", or "field_name", or ["parsed_headers", "header_name"].

  • error_type – Optional type of the error for extra metadata.

Returns:

Simple python object - exception converted to a common format.