skip to content

A partner sees 500s from a DRF endpoint when full_clean() fails in perform_create() or a PROTECT foreign key blocks a delete; why no 4xx, and how do you fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. two Django exceptions, none from DRF
  2. the handler maps only three families
  3. ProtectedError is an IntegrityError
  4. convert, then delegate
  5. rollback comes with the default

basics

~10 s

DRF's default handler only answers APIException, Http404 and Django's PermissionDenied. Django's ValidationError raised outside serializer validation and ProtectedError escape as 500s. Raise DRF exceptions at the source or map them in a custom handler.

solid answer

~40 s

The default `exception_handler` returns a response only for `APIException` subclasses, `Http404` and Django's `PermissionDenied`; everything else returns `None` and Django answers 500. `django.core.exceptions.ValidationError` is converted to a 400 only while a serializer is validating; raised from `full_clean()` in `perform_create()` it is just an unknown exception. Deleting a row that an `on_delete=PROTECT` foreign key still references raises `ProtectedError`, a subclass of `IntegrityError`, also a 500. Fix it where it happens: move the rule into serializer validation, or catch the error and raise a DRF exception such as a 409 `Conflict`. Centrally, a custom handler can convert Django's `ValidationError` into DRF's (for example with `as_serializer_error()`) and `ProtectedError` into a 409, then delegate to the default handler so headers and the `ATOMIC_REQUESTS` rollback still apply. Leave truly unexpected exceptions as 500s.

code

python · 19 lines
python
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import ProtectedError
from rest_framework import exceptions, status
from rest_framework.serializers import as_serializer_error
from rest_framework.views import exception_handler


class Conflict(exceptions.APIException):
    status_code = status.HTTP_409_CONFLICT
    default_detail = "The resource is still referenced by other records."
    default_code = "conflict"


def partner_exception_handler(exc, context):
    if isinstance(exc, DjangoValidationError):
        exc = exceptions.ValidationError(as_serializer_error(exc))
    elif isinstance(exc, ProtectedError):
        exc = Conflict()
    return exception_handler(exc, context)

go deeper

for a junior

Know that only DRF exceptions plus Django's Http404 and PermissionDenied become clean error responses; other exceptions are 500s.

for a middle

Explain where Django's ValidationError is converted automatically and where it is not, and how to raise a 409 for a protected delete.

for a senior

Diagnose 500s from logs, map known Django and database errors at the source or centrally, keep the rollback by delegating to the default handler, and leave real bugs as 500s.

for a principal

Treat every 500 on a partner API as a contract defect, and set a policy for which database conditions become documented 4xx codes.

## Why these become 500s Django REST Framework's default `exception_handler` has exactly three branches that produce a response: 1. `django.http.Http404`, converted to `NotFound` (404); 2. `django.core.exceptions.PermissionDenied`, converted to DRF's `PermissionDenied` (403); 3. any `rest_framework.exceptions.APIException` subclass. Anything else makes it return `None`; `handle_exception()` re-raises and Django returns a 500 through its own error handling. Two common Django exceptions fall into that gap: - **`django.core.exceptions.ValidationError`**. DRF converts it to a 400 **only during serializer validation**: field validators and `validate()` run inside `run_validation()`, which turns Django's error into DRF's form, codes included. Called anywhere else, for example `instance.full_clean()` in `perform_create()` after `is_valid()` has passed, it is an unknown exception. - **`django.db.models.ProtectedError`**. `on_delete=models.PROTECT` makes `delete()` raise it when related rows still exist, such as deleting a company that still has job postings. It subclasses `IntegrityError`; so does `RestrictedError` for `RESTRICT`. A unique-constraint race that slips past validation raises a plain `IntegrityError`. All are 500s. The partner sees a server error for what is really a client-side conflict, and your error tracking fills with noise. ## Fix at the source first | Problem | Better place | Result | |---|---|---| | model rules in `full_clean()` | serializer validation, where Django errors are converted | 400 with field errors | | deleting a protected company | `perform_destroy()` catches `ProtectedError`, raises a 409 `APIException` subclass | 409 with a clear code | | unique race at insert | catch `IntegrityError` around the save, raise the 409 or a `ValidationError` | 409 or 400 | When catching a database error inside a request that runs in a transaction (for example with `ATOMIC_REQUESTS`), wrap the write in its own `transaction.atomic()` block so only that savepoint is rolled back and the connection stays usable. ## Or map centrally in the handler A custom `EXCEPTION_HANDLER` can translate known Django exceptions before delegating: - Django `ValidationError` becomes DRF `ValidationError(as_serializer_error(exc))`, keeping field keys and codes (`as_serializer_error` lives in `rest_framework.serializers`); - `ProtectedError` becomes your `Conflict` exception. Then call the default `exception_handler`. Delegating matters because it: - builds the body and status the same way as every other error; - calls `set_rollback()`, which marks the `ATOMIC_REQUESTS` transaction for rollback, so writes made before the error are undone even though the view returns a normal response; - adds `Retry-After` or `WWW-Authenticate` when relevant. A handler that builds its own `Response` for a converted exception without delegating skips the rollback, and partial writes can commit. ## What not to do - Do not catch `Exception` and return 400: bugs become client errors and stop being reported. - Do not map every `IntegrityError` to one message; a foreign-key violation, a unique violation and a protected delete mean different things to the partner. - Do not rely on `DEBUG` behaviour: with `DEBUG = True`, DRF only switches the traceback to plain text for non-HTML renderers; it still returns a 500. ## Verifying the fix - Write API tests that delete a protected company and assert 409 with the documented code. - Assert that a failed create leaves no rows behind under `ATOMIC_REQUESTS`. - Alert on any remaining 500s from partner endpoints, since each one is a missing mapping or a real bug.

  • Why is Django's ValidationError a 400 when raised by a field validator but a 500 when raised in perform_create()?
    Field validators and `validate()` run inside the serializer's `run_validation()`, which catches Django's `ValidationError` and re-raises it as DRF's with the same messages and codes. `perform_create()` runs after `is_valid()` has finished, so nothing converts the exception and the default handler returns `None`.
  • What does set_rollback() do, and when would a custom handler skip it by mistake?
    It marks the current transaction for rollback on every database whose settings have `ATOMIC_REQUESTS` enabled and which is inside an atomic block. The default handler calls it for handled exceptions. A custom handler that builds its own `Response` without delegating skips it, so writes made before the error can commit.

saying these in an interview costs you the question

  • DRF turns Django's ValidationError into a 400 wherever a view raises it
  • ProtectedError maps to 409 Conflict out of the box
  • Catching Exception in the handler and returning 400 is a safe fix
  • A handled error response always rolls back the request's writes, even without ATOMIC_REQUESTS
  • With DEBUG on, DRF returns the traceback as a 400