Skip to content

HttpError and Custom Responses#

2024 iThome Ironman Challenge

This is the 21st article in the Django Ninja series.

In software development, error handling is a phase that cannot be ignored—yet it is often overlooked.

It is no exaggeration to say that when error handling is done well, no one notices—but when it is done poorly, the system suffers.

That's okay, let's still do our best to do it right.

Django Ninja uses Pydantic for data validation. When it fails, the default response is "422 Unprocessable Entity."

However, we sometimes need to respond with a "400 Bad Request" or other status codes to meet real-world business requirements or team development habits.

In short, for whatever reason, we want to customize the error message, format, and response status code rather than using Django Ninja's default 422 response—admittedly, the information in this default response is a bit verbose and its structure is somewhat complex because it has to be compatible with various situations.

This article will introduce how to customize error handling and responses using Django Ninja's built-in HttpError.

All code changes in this article can be referred to in this PR.

GitHub Example Project#

👉 Django-Ninja-Tutorial


Django Ninja's Automatic Error Handling#

In the previous article, we mentioned that if you throw a ValueError in a Schema validation method, Django Ninja will automatically capture and respond to it.

In fact, besides ValueError, Django Ninja also handles the following types of errors for you:

  • pydantic.ValidationError: The validation error from Pydantic. This is why we receive a 422 response directly when there are issues with Schema fields.
  • In addition, Django Ninja has a built-in ninja.errors.ValidationError, and these errors also return a 422 status code.
  • ninja.errors.HttpError: This is the focus of this article, which we will introduce below.

These are all errors that Django Ninja automatically captures, but not all of them return the default 422 response—the third one does not.


New Requirement: Using a 400 Response on Validation Failure#

Using the "Create User" API as an example, we want to implement a new requirement: when the confirmation password does not match, we want to respond with a "400 Bad Request" instead of 422.

What is the simplest way to do this?

Answer: Use Django Ninja's HttpError.

Below is the code change for the Schema, modifying only two lines!

...
from ninja.errors import HttpError  # Line 1

class CreateUserRequest(Schema):
    ...

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        if self.password != self.confirm_password:
            raise HttpError(400, 'Password and password confirmation must match')  # Line 2
        return self

Yes, it's that simple!

Simply replace the error thrown by the validation method from ValueError to HttpError.

It is worth noting that initializing an HttpError instance requires two parameters: the first is the HTTP status code, and the second is the error message.

Response Content#

Let's see how the response differs for the same validation failure:

// 400 Bad Request
{
    "detail": "Password and password confirmation must match"
}

It becomes the format we are familiar with—containing only the error message.

In contrast, look at the response when we threw ValueError in the previous article:

// 422 Unprocessable Entity
{
    "detail": [
        {
            "type": "value_error",
            "loc": [
                "body",
                "payload",
                "confirm_password"
            ],
            "msg": "Value error, Password and password confirmation must match",
            "ctx": {
                "error": "Password and password confirmation must match"
            }
        }
    ]
}

A huge difference, isn't it?


The Disadvantage of Using HttpError in Validation Methods#

Directly throwing HttpError in Schema validation methods is a convenient approach because it simplifies response handling.

We do not need to catch errors additionally or manually specify response formats. When validation fails, the API directly responds with the status code and error message we defined, which is both simple and convenient.

However, doing so is actually inappropriate due to several issues, such as reduced testability and restricted response flexibility. But the most critical reason is still the one we mentioned in the previous article—"Separation of Concerns."

Violating "Separation of Concerns"#

This approach violates the "Separation of Concerns" principle.

The responsibility of validation logic is to check the correctness of data, whereas responses should be handled by view functions.

Mixing response logic into the validation process couples these two parts that should be independent, resulting in confused responsibilities and making code maintenance more difficult.

Therefore, while using HttpError inside validation methods seems to easily implement the requirement, from the perspective of architectural design, placing the response handling in view functions is a more reasonable choice.

Don't worry, we will change this approach in the next article, but the main focus of this article remains HttpError.


Typical Scenario for HttpError: Using It in View Functions#

Compared to using HttpError in Schemas, executing it inside view functions is the proper way.

Below is a classic scenario.

Although data validation logic should be placed in Schemas as much as possible, not all validation is suitable for Schemas.

For example, a user's email field has "uniqueness"—it cannot be duplicated. Therefore, we want to confirm whether the email entered by the user duplicates the data in the database. If it does, we want to respond directly with 409 Conflict.

This is undoubtedly also a type of validation, but it involves a "database query."

This type of validation involving database queries is more suitable inside view functions than in Schemas. Database queries are heavier dynamic operations, which are fundamentally different from the static data checks of Schemas.

Therefore, we more commonly use HttpError in view functions to handle such requirements.

The code is added as follows:

@router.post(...)
def create_user(..., payload: CreateUserRequest):
    """
    Create User
    """
    if User.objects.filter(email=payload.email).exists():
        raise HttpError(409, 'A user with this email already exists')
    ...

The "pre-query" above is similar in result to the following approach:

try:
    user.save()
except IntegrityError:  # Django ORM uniqueness error
    raise HttpError(409, 'A user with this email already exists')

Except that one is validating and throwing an error beforehand, while the other is catching the error afterward (and then throwing it).

Response upon validation failure:

// 409 Conflict
{
    "detail": "A user with this email already exists"
}

It is indeed quite nice!


Why Not Directly Return a 409 Response?#

You might think:

Wait, why not just directly return a Python dictionary containing the error message? Why must we raise HttpError in the view function?

This idea would look roughly like this in code:

@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
    """
    Create User
    """
    if User.objects.filter(email=payload.email).exists():
        return 409, {"detail": "A user with this email already exists"}
    ...

Isn't this more intuitive?

That is a good question.

Key Points#

Let's first look at the key points in this code:

  • response={201: dict, 409: dict}: The "multi-status code response" mentioned in Part 13 comes in handy!
  • Using return instead of raise.
  • If you want to validate the error message format, you can define a Schema. This example is just a simplified version.

It does look nice and is very intuitive. In fact, when I wrote Django REST framework in the past, I always wrote it this way.

However, in Django Ninja, this approach will run into issues when using the "pagination decorator."

We will explain why in Article 25, "Pagination (Part 2) — Custom Pagination Class."

In short, at this stage, we only need to know that raising HttpError is a more appropriate approach in similar situations.


Summary#

In this article, we learned how to use Django Ninja's built-in HttpError to customize error responses, thereby avoiding the default 422.

We also explained why HttpError is not suitable for use in Schemas (although we did so temporarily 😅) and should be placed in view functions instead.

In the next article, we will improve the errors thrown by Schemas, explore the global error handling mechanism, and use the exception_handler decorator provided by Django Ninja to further enhance the API's error-handling capabilities.