Global Error Handling#

This is the 22nd article in the Django Ninja series.
In the previous article, we learned how to use HttpError and recommended that you only use it within view functions.
However, just doing that is far from sufficient for the error handling of a project's API. There are still at least three common questions to address:
- What is the best practice for validation methods in a Schema if we do not want to
raise HttpError? - How should we handle other types of errors, such as database operation errors?
- How do we ensure that the response format remains consistent across different API errors?
All of these issues point to a larger need: we need a comprehensive error handling mechanism.
This article aims to answer these questions. All code changes in this article can be referred to in this PR.
GitHub Example Project#
Switching to Django ValidationError#
Do you remember that the most original version of the validation method threw a ValueError?
ValueError is automatically captured by Django Ninja to return a 422 response. While this is good, it does not meet our customization needs.
Therefore, we later switched to HttpError. Although it is also captured, its response format and content are cleaner—and it allows us to customize the status code in addition to the error message.
However, as discussed in the previous article, although this is simple, it is not appropriate.
So what error should we throw instead?
Avoid Using Built-In Errors From Pydantic or Django Ninja#
As also mentioned in the previous article, both Pydantic and Django Ninja have their own built-in ValidationError.
However, they are mostly intended for internal framework use. Furthermore, the returned error formats are overly detailed, and their initialization methods are quite tedious. For example, Django Ninja's validation error requires initialization like this:
raise ValidationError(
[{'loc': ('confirm_password',),
'msg': 'Password and password confirmation must match',
'type': 'value_error'}])
This is not as simple as the "passing a single error message string" that we are familiar with.
Therefore, I do not recommend using these error types directly in your validation logic.
Prefer Django's ValidationError#
In Schema validation logic, we should prefer Django's built-in ValidationError.
It is designed with developer needs in mind. Initialization can be simple (a single string) or complex (a list or dict), making it suitable for most scenarios.
Here, we can just initialize it with a string. The code is modified as follows:
from django.core.exceptions import ValidationError
class CreateUserRequest(Schema):
password: str
confirm_password: str
@model_validator(mode='after')
def check_passwords_match(self):
if self.password != self.confirm_password:
raise ValidationError('Password and password confirmation must match')
We changed the original HttpError to Django's ValidationError.
We also initialized it with a simple "error message string," without the original first parameter "status code."
Django Ninja Does Not Automatically Handle These Errors#
After changing the thrown error type to Django's ValidationError, you might notice a problem: Django Ninja does not automatically capture these errors!
In other words, when we throw a ValidationError, Django Ninja will not automatically format it and return a 422 error response like it does with HttpError—instead, it will directly result in a 500 error.
We mentioned this at the end of Part 20: Data Validation (Part 2) — Pydantic Cross-Field Validation.
Now, we will introduce the concrete solution—exception_handler.
We need to handle these thrown errors ourselves, which is exactly where exception_handler comes into play.
Global Error Handlers — Exception Handlers#
To unify the handling of the same type of errors from different sources (not limited to Schema validation methods), we can use the @api.exception_handler decorator provided by Django Ninja.
This decorator allows us to define dedicated response logic for "specific types of errors" and apply it globally across the entire API.
Defining an exception_handler#
We can define a global error handler for Django's ValidationError to ensure that whenever this error is thrown anywhere, the handler will capture it, letting the API return our custom response format.
In the project's api.py file, add the following code:
# NinjaForum/api.py
from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse
from ninja import NinjaAPI
api = NinjaAPI(...)
api.add_router(...)
api.add_router(...)
# Newly added exception handler
@api.exception_handler(exc_class=ValidationError)
def django_validation_error_handler(
request: HttpRequest, exception: ValidationError
) -> HttpResponse:
"""
Handle Django ValidationError exceptions
"""
return api.create_response(
request, {'detail': exception.message}, status=400
)
We defined an exception handler function. When encountering Django's ValidationError, it returns an HTTP 400 response containing a custom error message, thus maintaining consistency in response formats.
The code is simple, but there are quite a few key points here. Let's analyze them one by one.
Key Points of Exception Handlers#
Let's start with the topic of "project organization."
1. Where Should Exception Handler Functions Be Placed?#
As mentioned earlier, the scope of this error handler is global, so it can be placed anywhere in the project.
However, it is still recommended to place it in the most suitable location. I believe there are primarily two choices:
- If you don't have many error handler functions, you can place them directly in the project's
api.py—which is what we did in our example. This aligns with the global management nature of the project'sapi.py. - If there are many error handlers, it is recommended to manage them in a separate Python module.
2. Function and Parameter Naming#
Time for my favorite topic again: "Naming" ☺️
An exception handler is a (decorated) function and theoretically should follow the function naming convention of "starting with a verb."
However, I used a more "noun-like" name: django_validation_error_handler.
This is because its essence is closer to a processing device or mechanism, rather than a function in the traditional sense.
Of course, this depends on how you look at it! You could also argue that since it performs the action of handling, it must be named starting with a verb. I completely agree with that.
Next is the exception parameter. The Django Ninja documentation usually names this exc. I personally dislike it because I feel exc is not intuitive at all and is a completely unnecessary abbreviation.
Taking a step back, I would rather use a single letter e—similar to the v in Pydantic validation methods.
3. Function Logic#
The logic of an exception handler function can be long or short, simple or complex, but it invariably does two things:
- Receives a specific type of error.
- Returns a specific HTTP response.
In this example, we receive Django's ValidationError and return a "400 Bad Request" response, and the content of the error message comes from the thrown error itself—which is defined by us.
This error handling flexibility is already quite good. If your ValidationError is initialized using a list or dict, this handler function will need to be written in a slightly more complex manner.
Trying It Out: Handling 404 Responses as an Example#
Let's implement another exception handler to handle the common 404 error.
Take the "Get Single Post Info" API as an example:
@router.get(...)
def get_post(request: HttpRequest, post_id: int) -> Post:
"""
Get single post info
"""
post = Post.objects.get(id=post_id)
return post
Currently, if the post ID entered by the frontend does not exist, the server will directly return a 500 error:
raise self.model.DoesNotExist( post.models.Post.DoesNotExist: Post matching query does not exist.
It even exposes internal details—which is clearly unacceptable! 🤣
Because the QuerySet's get method in Django ORM throws an error when no result is found or when multiple results are found. Since we did not catch or handle these errors, the server directly returns a 500 error.
The two types of errors are not the same—and the error messages should also differ. Let's handle the first case first.
Returning a 404 Response When No Result Is Found#
Following the introductions in these two articles, you have two ways to return a 404 response.
First, use HttpError directly:
try:
post = Post.objects.get(id=post_id)
except Post.DoesNotExist:
raise HttpError(404, 'Post does not exist')
This was the approach from our previous article, and is also highly recommended.
Second, use an exception handler:
# NinjaForum/api.py
from django.core.exceptions import ObjectDoesNotExist
...
@api.exception_handler(exc_class=ObjectDoesNotExist)
def object_does_not_exist_handler(
request: HttpRequest, exception: ObjectDoesNotExist
) -> HttpResponse:
"""
Handle Django ObjectDoesNotExist exceptions
"""
return api.create_response(
request, {'detail': 'Data not found'}, status=404)
Compared to the first approach, this method has its pros and cons:
- Pros: No need to modify the content of the view function (cleaner syntax), and it can capture
ObjectDoesNotExisterrors thrown by all APIs. (It is the parent class ofPost.DoesNotExist) - Cons: Cannot customize a "detailed" error message—because we don't know which model object the
ObjectDoesNotExisterror occurred on. - Of course, if you are willing to define individual exception handlers for different errors, you can achieve this!—For example, by only capturing
Post.DoesNotExist, you can write the error message as "Post does not exist." - But that requires defining many exception handlers, which is a bit tedious!
Which approach to choose depends on your specific situation.
Effect of the 404 Response#
- Using
HttpError:
- Using an exception handler:
Chapter 5 Summary#
Honestly, Chapter 5 has a lot of information. I spent a long time writing these four articles, and I even "refactored" them!—Originally, there were only two.
We first discussed custom Single-Field and Cross-Field Validation. We then learned increasingly elegant and comprehensive ways to handle errors raised by APIs.
If you have read from Part 1 all the way to this point, you can truly be proud of yourself.
Next Steps#
Is it going to be easier next?—Not really.
We are going to introduce common advanced features of APIs.
Compared to handling requests and responses, these features are "optional" but still very important for many API projects.
In the next chapter, we will explore these advanced features one by one and learn how to implement them in Django Ninja. Let's continue diving deep into the world of API development!