When I first started combining Django REST Framework (DRF) with Backbone.js and raw jQuery AJAX calls, I ran into a problem: error handling.
Whenever my AJAX call failed, Django responded with get_error_response
. But instead of gracefully handling errors on the client, the response was bubbling up as unhandled client-side errors. That made it impossible to show meaningful messages to my users.
My Starting Point The Basic Error Response
At first, my code looked like this:
# views.py
from rest_framework.response import Response
from rest_framework import status
def get_error_response(self):
return Response(
self.serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
This sends back a 400 Bad Request
whenever serializer validation fails. On paper, it’s correct. In practice, though, I misunderstood how jQuery/Backbone handles responses.
The Problem Why AJAX Didn’t Behave as I Expect
Here’s what I learned:
- In jQuery/Backbone, any non-2xx HTTP status (
400
,422
,500
, etc.) automatically goes to.fail()
or theerror
callback. - If I instead sent back
200 OK
with an"error_status": 400
in the JSON body, it tricked AJAX into calling.done()
— exactly the opposite of what I wanted.
So the real issue wasn’t Django, but how I was structuring error payloads.
The Fix A Consistent Error Schema
To make my client handle errors cleanly, I wrapped all error responses in a consistent JSON envelope. That way, the structure is always predictable, and the HTTP status still signals an error.
# views.py
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
import uuid
from datetime import datetime, timezone
class ExampleView(APIView):
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return self.error_response(
status_code=status.HTTP_400_BAD_REQUEST,
message="Validation failed",
errors=serializer.errors,
code="VALIDATION_ERROR"
)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get_serializer(self, *args, **kwargs):
return self.serializer_class(*args, **kwargs)
def error_response(self, status_code, message, errors=None, code=None):
correlation_id = str(uuid.uuid4())
payload = {
"error": {
"message": message,
"code": code or "UNKNOWN_ERROR",
"status": status_code,
"errors": errors or {},
"timestamp": datetime.now(timezone.utc).isoformat(),
"correlation_id": correlation_id,
}
}
return Response(payload, status=status_code)
Why This Work
- The response always returns a true HTTP error status (400, 401, 422, etc.), so it always lands in
.fail()
. - My client can consistently read from
responseJSON.error.message
,responseJSON.error.code
, andresponseJSON.error.errors
. - A
correlation_id
helps me debug logs in production.
Bonus: Global Exception Handling in DRF
If I want this consistency across my entire project, I can override DRF’s exception handler:
# settings.py
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "myapp.exceptions.custom_exception_handler",
}
Inside custom_exception_handler
, I wrap all errors in the same envelope format. This way, I don’t have to repeat error_response
everywhere.
Client Side Example: jQuery
$.ajax({
url: "/api/example/",
method: "POST",
contentType: "application/json",
data: JSON.stringify({ email: "invalid" })
})
.done(function (data) {
console.log("Created:", data);
})
.fail(function (jqXHR) {
var payload = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : null;
var message = (payload && payload.message) || "Request failed.";
alert(message);
if (payload && payload.errors) {
Object.keys(payload.errors).forEach(function (field) {
renderFieldError(field, payload.errors[field].join(", "));
});
}
});
Client Side Example Backbone
var Model = Backbone.Model.extend({
urlRoot: "/api/example/"
});
var m = new Model({ email: "invalid" });
m.save(null, {
success: function (model, resp) {
console.log("Saved", resp);
},
error: function (model, xhr) {
var payload = xhr.responseJSON && xhr.responseJSON.error ? xhr.responseJSON.error : null;
var message = (payload && payload.message) || "Request failed.";
showToast(message);
if (payload && payload.errors) {
renderErrors(payload.errors);
}
}
});
Extra Enhancements I Added
- Use specific status codes: 422 for validation, 409 for conflicts, 429 for throttling.
- Attach error codes: I defined them in my serializer validators so the client can show friendly messages.
- Log correlation IDs: They’ve saved me countless hours debugging production issues.
- Unit tests: I wrote small tests to confirm the error envelope is always returned.
Final Thought
At first, I thought the solution was to hack around Django’s responses and send 200 OK
with custom flags. But the real fix was simpler: return proper HTTP error statuses and standardize my error schema.
Now, all my AJAX .fail()
handlers behave consistently, my users see clear error messages, and I have a debugging trail with correlation IDs.
If you’re combining Django REST Framework with Backbone or jQuery, don’t fight the HTTP spec. Embrace it, return proper error statuses, and your frontend will thank you.