How Can I Fix Django Error Status Handling in AJAX

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 the error 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, and responseJSON.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.

Related blog posts