Filter concepts by levelShowing all levels.

Django · Section 54

API Error Handling

Level
advanced
Read
34 min
Concepts
4

A status code is chosen by what the client should do next, which is the only question the status line can answer. 400 and 422 say fix the request; 401 says authenticate; 403 says you are known and refused; 404 says it is not there, or that you may not know it is; 409 says the request is fine and the state is not; 429 says wait for `Retry-After`. In DRF you raise the matching exception and the framework sets the code — except for 409, which it does not ship. The 5xx family splits by who generated the response: a 500 came from your code, so there is a traceback and a correlation id, while 502, 503 and 504 usually came from the proxy because a worker died, nothing was available, or the application outran the proxy's patience — which is why they leave nothing in the Django log and why timeouts must be layered innermost-first. A single `EXCEPTION_HANDLER` makes the error shape a property of the project rather than of whoever wrote the view: call DRF's default first to keep its status-code mapping and required headers, rewrite only `response.data`, and treat a `None` return as the unrecognised-exception path where the traceback gets logged. A correlation id generated in middleware — echoed on every response, bound to every log line — is what makes redaction affordable. And redaction is the last piece: never a stack trace, SQL, credential, secret, or internal state in a response body, and never two responses that differ in wording, status, or timing when the difference is itself the secret.

What is true here

  1. Choose the 4xx by the remedy it implies for the client; an empty collection is 200 with [], not 404.
  2. 500 is your code; 502/503/504 are the proxy answering because the app died, was unavailable, or was too slow.
  3. One EXCEPTION_HANDLER gives the whole API one error shape — call DRF's default first, then rewrite the body.
  4. A None return from the default handler is the unrecognised-exception path: log the traceback there, return a generic body.
  5. Never expose tracebacks, SQL, credentials, secrets, or internal state — and never let two responses differ when the difference is the secret.

What you will be able to do

  • Choose precise status codes that tell a client what to do without reading prose
  • Tell which layer generated a 5xx, and where the evidence for it lives
  • Write one exception handler that gives every endpoint the same error envelope
  • Trace a user-reported failure to an exact log line via a correlation id
  • Write error messages that are actionable without disclosing anything about the server or its data
Every failure path, and what the client ends up holding
worker killed/ timed outdefaultreturns None

Something goes wrong in a view

A DRF exception

ValidationError · NotAuthenticated · PermissionDenied · NotFound · Throttled

Anything else

KeyError, OperationalError, a bug

EXCEPTION_HANDLER

DRF default first, then rewrite response.data

Log with the correlation id

warning for 4xx · exception + traceback for 5xx

4xx · one envelope

type, detail, errors, correlation_id — plus Retry-After or WWW-Authenticate

500 · the same envelope

generic detail; the traceback never leaves the log

The app died, stalled, or was unavailable

no Django response at all

502 / 503 / 504 from the proxy

no traceback, no correlation id — look in the proxy log

  • Something goes wrong in a view
    • leads to A DRF exception
    • on error, leads to Anything else
    • on error, leads to The app died, stalled, or was unavailable (worker killed / timed out)
  • A DRF exception — ValidationError · NotAuthenticated · PermissionDenied · NotFound · Throttled
    • leads to EXCEPTION_HANDLER
  • Anything else — KeyError, OperationalError, a bug
    • on error, leads to EXCEPTION_HANDLER (default returns None)
  • EXCEPTION_HANDLER — DRF default first, then rewrite response.data
    • leads to Log with the correlation id
  • Log with the correlation id — warning for 4xx · exception + traceback for 5xx
    • leads to 4xx · one envelope
    • on error, leads to 500 · the same envelope
  • 4xx · one envelope — type, detail, errors, correlation_id — plus Retry-After or WWW-Authenticate
  • 500 · the same envelope — generic detail; the traceback never leaves the log
  • The app died, stalled, or was unavailable — no Django response at all
    • on error, leads to 502 / 503 / 504 from the proxy
  • 502 / 503 / 504 from the proxy — no traceback, no correlation id — look in the proxy log

The 4xx family

Validation errors and the codes that tell a client what to do next.

Validation errors and the 4xx status codes

coreintermediate

A 4xx says the problem is on the client's side, and the specific code says what to do about it. **400** — the request is malformed or fails validation; fix it and retry. **401** — no valid credentials; authenticate and retry. **403** — credentials are fine, the action is refused; retrying will not help. **404** — no such resource, or you may not know it exists. **409** — the request is valid but conflicts with the current state, such as a duplicate or a stale version. **422** — well-formed and syntactically valid, but it fails a business rule (DRF returns 400 for this by default). **429** — you are rate limited; wait for `Retry-After` and try again. In DRF you rarely write these numbers: you raise the matching exception and the framework sets the code.

Think of it as

Pick the code by asking what the client should do next, because that is the only question the status line can answer. Retry after fixing input? 400 or 422. Retry after getting credentials? 401. Do not retry at all? 403. Wait then retry unchanged? 429. Retry only if state changes? 409. Read that way, the pairs that get confused separate cleanly. 401 versus 403 is "who are you?" versus "not you"; sending 403 to an unauthenticated caller tells a client to give up when refreshing a token would have worked. 403 versus 404 is a disclosure decision, not a correctness one — a 403 confirms the resource exists, so when existence itself is sensitive you filter the queryset and return 404 for both cases. 400 versus 409 is remedy again: a 400 says the request is wrong, a 409 says the world is. And the last discipline is that an empty result is not an error — `GET /orders/?status=paid` matching nothing is a 200 with an empty list, because the request was valid and the answer is "none".

python
class Conflict(APIException):
    status_code = 409
    default_detail = "Conflicts with the current state of the resource."
    default_code = "conflict"

What we're doing: One endpoint, five distinct failures, each with the code that tells the client what to do.

orders/views.pypython
class OrderCancelView(APIView):
    permission_classes = [IsAuthenticated]
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "cancel"

    def post(self, request, pk):
        # 401 — no credentials at all; raised by DRF before this line runs.
        # 429 — over the scope's rate; raised by the throttle, with Retry-After.

        order = get_object_or_404(
            Order.objects.filter(customer=request.user), pk=pk
        )                                    # 404, and it hides other customers' ids

        if order.status == Order.Status.SHIPPED:
            raise Conflict("Order has shipped and can no longer be cancelled.")   # 409

        serializer = CancelSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)                                 # 400

        if serializer.validated_data["refund_amount"] > order.total:
            raise ValidationError(
                {"refund_amount": "Cannot exceed the order total."})              # 400

        if not request.user.has_perm("orders.cancel_order"):
            raise PermissionDenied("Cancelling requires the orders.cancel role.") # 403

        order.cancel(**serializer.validated_data)
        return Response(OrderSerializer(order).data, status=200)
10–12
Looking the order up inside a queryset already scoped to the caller means someone else's order id is a 404, not a 403 — the response cannot be used to confirm that the order exists.
14–15
409, because nothing about the request is wrong. Retrying with different input will not help; only the order's state changing would.
17–18
`raise_exception=True` turns validation failures into a 400 with a per-field mapping, so the client can highlight the field rather than parse a sentence.
20–22
A rule spanning two values is still a 400 here — the API uses 400 for every request-content failure rather than mixing 400 and 422, which would leave clients guessing.
24–25
403 and not 401: the caller is authenticated. Sending 401 would tell the client to refresh a token that is perfectly valid.

Why this works: Each code names a different remedy, so a client can act without reading prose: fix input (400), wait (429), refresh credentials (401), give up (403), or retry when state changes (409).

Returning 404 for an empty list

Wrong

python
orders = Order.objects.filter(customer=request.user, status="paid")
if not orders.exists():
    raise NotFound("No paid orders.")
return Response(OrderSerializer(orders, many=True).data)

Better

python
orders = Order.objects.filter(customer=request.user, status="paid")
return Response(OrderSerializer(orders, many=True).data)   # 200 with [] when empty

What you see: A dashboard shows an error banner for a brand-new account that simply has no orders yet, and the client has to special-case a 404 that means "success, nothing matched" against a 404 that means "this endpoint does not exist".

Why: 404 means the *resource* was not found, and a collection endpoint always exists — it just happens to contain nothing right now. Overloading the code makes two genuinely different situations indistinguishable to a client. An empty collection is a successful answer to a valid question, so it is 200 with an empty array.

The 4xx family, grouped by the remedy each one implies

Fix the request

400 Bad Request

malformed JSON, wrong type, missing required field

422 Unprocessable

valid shape, fails a business rule — DRF uses 400 unless told otherwise

405 Method Not Allowed

the URL exists; this verb does not

Fix who you are

401 Unauthorized

authenticate and retry — needs a WWW-Authenticate header

403 Forbidden

we know who you are; retrying will not help

404 instead of 403

when existence itself is the secret — filter the queryset

Wait, or change the world

409 Conflict

duplicate, stale version, already-shipped order

429 Too Many Requests

always with Retry-After — the client waits and repeats verbatim

200 with []

an empty result is not an error — do not reach for 404

  • The request failed, and it is the client's side
  • Fix the request — the body or the URL is wrong
    • 400 Bad Request — malformed JSON, wrong type, missing required field
    • 422 Unprocessable — valid shape, fails a business rule — DRF uses 400 unless told otherwise
    • 405 Method Not Allowed — the URL exists; this verb does not
  • Fix who you are — identity, not input
    • 401 Unauthorized — authenticate and retry — needs a WWW-Authenticate header
    • 403 Forbidden — we know who you are; retrying will not help
    • 404 instead of 403 — when existence itself is the secret — filter the queryset
  • Wait, or change the world — the request is fine; the state is not
    • 409 Conflict — duplicate, stale version, already-shipped order
    • 429 Too Many Requests — always with Retry-After — the client waits and repeats verbatim
    • 200 with [] — an empty result is not an error — do not reach for 404

Choosing a 4xx by what the client should do next

Choosing a 4xx by what the client should do next
CodeMeaningClient's next moveDRF exception
400malformed or fails validationfix the request, retry`ValidationError`, `ParseError`
401no valid credentialsauthenticate, retry`NotAuthenticated`, `AuthenticationFailed`
403identified, and refuseddo not retry`PermissionDenied`
404no such resource (or you may not know)do not retry`NotFound`, `Http404`
405method not allowed on this URLuse a different method`MethodNotAllowed`
409conflicts with current stateretry only if state changesdefine your own `APIException`
422well-formed, fails a business rulechange the values, retrya subclass with `status_code = 422`
429rate limitedwait `Retry-After`, retry unchanged`Throttled`

Together

python
raise ValidationError({"quantity": "Exceeds available stock."})   # 400
raise PermissionDenied("Not your order.")                        # 403
raise NotFound()                                                 # 404
raise Throttled(wait=42)                                         # 429 + Retry-After: 42

Remember: Choose the code by what the client should do next: 400/422 fix the input, 401 authenticate, 403 give up, 404 it is not there (or you may not know), 409 the state conflicts, 429 wait for `Retry-After`. In DRF you raise the exception and the framework sets the code — and it ships none for 409, so define one. 403 confirms existence, so return 404 for both cases when existence is the secret. An empty result is 200 with `[]`, never 404.

See also: server errors and gateway codes · global exception handlers and stable error payloads · http semantics and status codes · custom basepermission and object level checks

Advertisement

The 5xx family

500 versus the gateway codes, and which log holds the evidence for each.

500, and the 502 / 503 / 504 family

standardintermediate

A 5xx says the failure is on your side. **500** is your own code raising something it did not handle — it came from Django, so it appears in your logs with a traceback. The other three usually did not come from your code at all: **502 Bad Gateway** means the proxy reached your application and got something unusable, typically because the worker died mid-request; **503 Service Unavailable** means nothing was there to serve it, such as every worker being busy or the app being down; **504 Gateway Timeout** means the application took longer than the proxy was willing to wait. Recognising which of the four you are looking at tells you where to go looking, and only one of them has a traceback waiting for you.

Think of it as

The useful distinction is who generated the response. A 500 is Django's — your exception handler ran, so there is a log line, a correlation id, and a stack trace naming the line that failed. A 502, 503 or 504 is the *proxy* answering on your behalf, which means your application either never produced a response or produced one the proxy could not use. That is why searching the Django log for a 504 finds nothing: the request may still have been running, and often completed successfully after the client had already been given up on. Two operational consequences follow. First, look in the right log — nginx or the load balancer for the gateway codes, Django for 500s. Second, the timeouts have to be layered deliberately: the database `statement_timeout` should be shorter than the Gunicorn worker timeout, which should be shorter than the proxy read timeout, so a slow query surfaces as a clean 500 you can attribute rather than as a 504 with no trace of what was slow. A 5xx is also the only class that legitimately triggers a retry from a well-behaved client, so returning one for a client mistake makes the client hammer you for a request that can never succeed.

python
def handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:                      # DRF did not recognise it -> a 500
        logger.exception("unhandled", extra={"correlation_id": cid})
    return response

What we're doing: Make a slow request fail as an attributable 500 rather than a silent 504.

config/settings.py + deploy/gunicorn.conf.pypython
# settings.py — the innermost timeout, so the database gives up first
DATABASES = {
    "default": {
        ...,
        "OPTIONS": {"options": "-c statement_timeout=15000"},   # 15s
    },
}

# gunicorn.conf.py — kills a worker only after the database has already given up
timeout = 30
graceful_timeout = 30
workers = 4

# nginx.conf — the outermost, so the proxy is the last to lose patience
#   proxy_read_timeout 60s;
5–6
15 seconds at the database. A runaway query is cancelled here and raises `OperationalError`, which becomes a 500 naming the query in the traceback.
10
Thirty seconds at Gunicorn — long enough that a killed worker means something other than a slow query, which makes 502s meaningful rather than routine.
15
Sixty seconds at nginx. Because it is the loosest limit, a 504 now genuinely means "nothing inside gave up first", which is a much narrower and more useful signal.

Why this works: Ordering the timeouts innermost-to-outermost makes each code diagnostic: a 500 with a query in the traceback, a 502 meaning a worker actually died, a 504 meaning the whole stack was slow rather than one query. Inverting the order produces 504s with no trace of what caused them.

Where each 5xx is generated on the way to your view

Client

sees only the status code — and retries 5xx, which is why misusing one is expensive

CDN / load balancer

503 when no healthy backend is available; the first place a request can fail without touching you

Reverse proxy (nginx)

504 when the app exceeds proxy_read_timeout · 502 when the worker returns something unusable

Gunicorn master + workers

--timeout kills a worker mid-request, which the proxy then reports as 502

Django + DRF

500 — the only one of the four with a traceback in your log and a correlation id in the response

PostgreSQL

statement_timeout cancels a slow query, surfacing as a clean 500 rather than a mysterious 504

  1. Client — sees only the status code — and retries 5xx, which is why misusing one is expensive
  2. CDN / load balancer — 503 when no healthy backend is available; the first place a request can fail without touching you
  3. Reverse proxy (nginx) — 504 when the app exceeds proxy_read_timeout · 502 when the worker returns something unusable
  4. Gunicorn master + workers — --timeout kills a worker mid-request, which the proxy then reports as 502
  5. Django + DRF — 500 — the only one of the four with a traceback in your log and a correlation id in the response
  6. PostgreSQL — statement_timeout cancels a slow query, surfacing as a clean 500 rather than a mysterious 504

Four server-side codes, and where to look

Four server-side codes, and where to look
CodeWho produced itTypical causeWhere the evidence is
500Djangoan unhandled exception in your codethe application log — traceback + correlation id
502the proxyworker died mid-request; OOM kill; a bad responseproxy log + kernel/OOM log; no Django traceback
503the proxy or app serverall workers busy, app down, health check failingproxy log, worker saturation metrics
504the proxythe app exceeded the proxy read timeoutproxy log; the slow query is often in the DB log

Together

bash
# Layered so slowness surfaces where it can be attributed:
#   PostgreSQL statement_timeout   15s
gunicorn --timeout 30 config.wsgi
#   nginx proxy_read_timeout       60s

Remember: A 500 is yours — Django raised it, so there is a traceback and a correlation id. A 502, 503 or 504 is usually the proxy answering because your application died, was unavailable, or was too slow, so the evidence is in the proxy log rather than Django's. Layer timeouts innermost first — `statement_timeout` < Gunicorn `--timeout` < proxy read timeout — so slowness surfaces as an attributable 500. And never return a 5xx for a client mistake: 5xx is the class clients retry.

See also: the client error status codes · global exception handlers and stable error payloads · infra settings

Advertisement

One handler, one shape

The global exception handler, a stable payload, and correlation IDs that join a report to a log line.

Global exception handlers, stable payloads, and correlation IDs

coreadvanced

DRF routes every exception raised inside a view through one function, named by the `EXCEPTION_HANDLER` setting. Replacing it is how you give an API a single error shape without touching a single view. The handler receives the exception and a context dict, and DRF's own default already maps its exception classes to the right status codes — so the pattern is to call the default first, then reshape its body. When the default returns `None`, the exception was one DRF does not recognise and is on its way to becoming a 500: that is the hook where you log it. A correlation id ties the three together — echoed in the response, attached to every log line for that request, so a support ticket containing one id leads straight to the traceback.

Think of it as

The value of a single handler is that error shape becomes impossible to get wrong rather than merely documented. Any per-view error formatting is a shape a future view will forget, and the shape clients depend on most is the one they exercise least. Delegating to DRF's default first is what keeps this cheap: the framework already knows that `NotAuthenticated` is 401, `Throttled` is 429 with `Retry-After`, `Http404` is 404 — reimplementing that mapping is work with no upside, so the handler only rewrites `response.data`. The `None` return is the important branch and the one people miss: it means "not an API exception", which is a programming error on its way to a 500, and it is the last place in the request where you still hold both the exception and the request context. Log there, with the correlation id, and the response can then be entirely generic without losing anything. On the id itself, generate it in middleware rather than in the handler, because it should exist on successful requests too — it is what lets you trace a request whose *behaviour* was wrong even though its status was 200.

python
def handler(exc, context):
    response = exception_handler(exc, context)   # DRF's default first
    if response is None:
        ...                                       # unrecognised -> log, then 500
    return response

What we're doing: One correlation id per request, one error envelope for the whole API, and the traceback only ever in the log.

common/middleware.py + common/exceptions.pypython
_correlation_id = contextvars.ContextVar("correlation_id", default="")


class CorrelationIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        cid = request.headers.get("X-Correlation-Id") or uuid4().hex
        _correlation_id.set(cid)
        request.correlation_id = cid
        response = self.get_response(request)
        response["X-Correlation-Id"] = cid        # on successes too, not just errors
        return response


def handler(exc, context):
    response = exception_handler(exc, context)
    cid = getattr(context["request"], "correlation_id", "")

    if response is None:
        logger.exception("unhandled_exception", extra={"correlation_id": cid})
        return Response(
            {"type": "internal_error",
             "detail": "An unexpected error occurred.",
             "correlation_id": cid},
            status=500,
        )

    body = {
        "type": getattr(exc, "default_code", "error"),
        "detail": exc.detail if isinstance(exc.detail, str) else "Request failed.",
        "correlation_id": cid,
    }
    if isinstance(exc, ValidationError):
        body["type"] = "validation_error"
        body["errors"] = exc.detail

    logger.warning("api_error", extra={"correlation_id": cid, "type": body["type"]})
    response.data = body
    return response
9–11
Accepting an inbound `X-Correlation-Id` lets a caller's id flow through your logs, so a distributed trace stays joined across services instead of restarting at your boundary.
13
Echoing the header on every response, not only failures, means a report of "the data looked wrong" is traceable even though the status was 200.
21–28
The `None` branch. This is the last point at which both the exception and the request are in hand, so logging here is what makes a 500 findable — and the response body then carries nothing internal.
30–33
One envelope for every recognised failure. `default_code` is the stable string clients branch on; `detail` is prose and may be reworded freely.
38
Logging recognised errors at `warning` keeps 4xx noise out of the error channel while still leaving a searchable line per failure.

Why this works: Three properties fall out of one function and one middleware: every error has the same shape, every 500 is logged with a traceback the response never carries, and any request — successful or not — can be found in the logs from the id in a screenshot.

Formatting errors in each view with try/except

Wrong

python
def post(self, request):
    try:
        ...
    except Order.DoesNotExist:
        return Response({"error": "not found"}, status=404)
    except ValueError as exc:
        return Response({"message": str(exc)}, status=400)

Better

python
def post(self, request):
    order = get_object_or_404(self.get_queryset(), pk=pk)   # -> NotFound -> handler
    serializer.is_valid(raise_exception=True)               # -> ValidationError -> handler
    ...
# Every failure flows through EXCEPTION_HANDLER and comes out in the same shape.

What you see: The API accumulates a different error shape per view — `error`, `message`, `detail`, `errors` — and no correlation id anywhere, so a support ticket saying "it failed at about 3pm" cannot be matched to a log line.

Why: Per-view formatting means the shape depends on which view failed, which is exactly the thing clients cannot discover ahead of time. Worse, a broad `except ValueError` swallows programming errors that should have become logged 500s, so the bug disappears instead of being recorded. Raising the framework's exceptions and letting one handler render them makes the shape a property of the project rather than of whoever wrote the view.

One exception, from the view to the client and the log
View
DRF dispatcher
Your handler
Logger
Client
  1. 1. raise Conflict("already shipped")
  2. 2. handler(exc, context)context carries view, args, kwargs, request
  3. 3. exception_handler(exc, context)the default first — it already knows the status code
  4. 4. Response(status=409)
  5. 5. warning · code=conflict · correlation_id
  6. 6. 409 {type, detail, correlation_id}
  7. 7. an unhandled KeyError
  8. 8. handler(exc, context)
  9. 9. exception_handler → Nonenot an API exception — this is the 500 path
  10. 10. logger.exception — full traceback + correlation_id
  11. 11. 500 {type: internal_error, correlation_id}generic body; the traceback stays in the log
  1. View → DRF dispatcher: raise Conflict("already shipped")
  2. DRF dispatcher → Your handler: handler(exc, context) (context carries view, args, kwargs, request)
  3. Your handler → DRF dispatcher: exception_handler(exc, context) (the default first — it already knows the status code)
  4. DRF dispatcher → Your handler: Response(status=409)
  5. Your handler → Logger: warning · code=conflict · correlation_id
  6. Your handler → Client: 409 {type, detail, correlation_id}
  7. View → DRF dispatcher: an unhandled KeyError
  8. DRF dispatcher → Your handler: handler(exc, context)
  9. Your handler → DRF dispatcher: exception_handler → None (not an API exception — this is the 500 path)
  10. Your handler → Logger: logger.exception — full traceback + correlation_id
  11. Your handler → Client: 500 {type: internal_error, correlation_id} (generic body; the traceback stays in the log)

What reaches the handler, and what does not

What reaches the handler, and what does not
Raised byReaches `EXCEPTION_HANDLER`?Notes
`raise ValidationError` in a serializeryesdefault maps it to 400 with the field mapping
`PermissionDenied` from a permission classyes403, or 401 if unauthenticated and a challenge exists
`Http404` / `get_object_or_404`yesDRF converts it to `NotFound`
An unhandled `KeyError` in a viewyes — as `None` from the defaultlog it here; it becomes a 500
An exception in Django middleware**no**outside DRF — use `process_exception` or Django's handler500
A URL that matches no pattern**no**resolved before any view; Django returns the 404

Together

python
REST_FRAMEWORK = {"EXCEPTION_HANDLER": "common.exceptions.handler"}

Remember: One `EXCEPTION_HANDLER` gives the whole API one error shape without touching a view. Call DRF's default first — it already maps exceptions to status codes and adds `Retry-After` and `WWW-Authenticate` — then rewrite only `response.data`. A `None` return means an unrecognised exception heading for a 500: log it there with the traceback, and return a generic body. Generate a correlation id in middleware so it exists on successful requests too, echo it in a header, and bind it to every log line.

See also: safe error messages · the client error status codes · request validation response schemas and error contracts · custom and async middleware

Advertisement

Safe error messages

The five things that must never leave the server, and the leaks that come from differences rather than contents.

Safe error messages, and what must never leave the server

coreadvanced

An error response should tell the caller what they can act on, and nothing about how the server is built. Five things must never appear in one: **stack traces** (they name your files, packages, and versions), **SQL statements** (they hand over your schema), **credentials** and **secrets** (a connection string in an exception message is a leaked password), and **sensitive internal state** (hostnames, private IPs, queue names, other users' data). All five belong in the log instead, keyed by a correlation id the response does echo. There is a second, quieter rule: an error message must not *distinguish* things the caller should not be able to distinguish — "no such user" and "wrong password" have to read identically, or the endpoint becomes an account-enumeration tool.

Think of it as

Two failure modes hide here, and only one is obvious. The obvious one is leaking a payload — a traceback rendered to the client, `DEBUG = True` in production, a `str(exc)` that happens to contain a connection string. The subtle one is leaking through *differences*: two responses that vary in wording, in status code, or in timing tell an attacker something about state they were never shown. A login form saying "no account with that email" is a working directory of your users; a 403 where a 404 was expected confirms a resource exists; a password check that returns fast for unknown users and slow for known ones leaks the same fact through the clock. So the discipline is not only "redact the body" but "make the responses identical" for cases the caller must not tell apart. Everything you strip out still has to exist somewhere, which is why this rule and the correlation id are one design and not two: the operator gets the full traceback in the log, the caller gets a generic sentence plus an id, and support joins them. Django gets you most of the way by default — `DEBUG = False` returns a plain 500 — but that default is exactly one settings mistake away from the worst version of this.

python
logger.exception("charge_failed", extra={"correlation_id": cid})
return Response(
    {"type": "internal_error", "detail": "An unexpected error occurred.",
     "correlation_id": cid},
    status=500,
)

What we're doing: A login endpoint that gives an attacker nothing — same body, same status, and no fast path for unknown emails.

accounts/views.pypython
GENERIC = {"type": "invalid_credentials",
           "detail": "Email or password is incorrect."}


class LoginView(APIView):
    permission_classes = [AllowAny]
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "login"

    def post(self, request):
        serializer = LoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        email = serializer.validated_data["email"]
        password = serializer.validated_data["password"]

        user = User.objects.filter(email__iexact=email).first()
        if user is None:
            # Hash anyway: an early return here is measurably faster,
            # and the difference alone reveals which emails are registered.
            User().set_password(password)
            return Response(GENERIC, status=400)

        if not user.check_password(password):
            logger.info("login_failed", extra={"user_id": user.pk})
            return Response(GENERIC, status=400)

        return Response(issue_tokens(user), status=200)
1–2
One constant, used by both failure paths. Two separately-written messages drift apart the first time someone improves the wording of one of them.
8
A tight throttle scope. Uniform responses stop an attacker learning from a single request; the rate limit stops them learning from a million.
17–20
Hashing a password for a user who does not exist looks wasteful and is the point: `check_password` takes tens of milliseconds, so skipping it makes unknown emails answer measurably faster.
23–25
The failure is logged with the user id — the operator can see which accounts are being targeted, while the caller still learns nothing.

Why this works: Enumeration is defeated by making the two cases indistinguishable in body, status, and timing. Any one of those left different reintroduces the leak on its own.

Different messages for "unknown account" and "wrong password"

Wrong

python
if user is None:
    return Response({"email": ["No account with that email."]}, status=400)
if not user.check_password(password):
    return Response({"password": ["Incorrect password."]}, status=400)

Better

python
if user is None or not user.check_password(password):
    return Response(GENERIC, status=400)

What you see: Someone submits a list of email addresses and reads off which ones are registered, purely from which of two messages comes back — no credentials needed, no alarm raised, and every response is a perfectly ordinary 400.

Why: Helpfulness and confidentiality are in direct conflict here, and confidentiality wins on authentication endpoints. Knowing that an address has an account is useful to an attacker on its own — it narrows a credential-stuffing list and confirms a person uses your service. Field-specific messages are correct on a normal form and wrong on this one, because the field being wrong is itself the secret.

The same failure, leaked and contained

Leaked — what the caller should never see

  • +Names the database host, its private IP, and the port.
  • +Reveals the ORM, the driver, and their versions — a version-specific CVE becomes a targeted attempt.
  • +The SQL discloses table and column names.
  • +"No account with that email" turns login into an account directory.
  • +Now stored in the caller's logs, error tracker, and support screenshots.

Contained — actionable, and nothing more

  • One generic sentence; the traceback is in the log only.
  • A correlation id joins the caller's report to the exact log line.
  • A stable `type` the client can branch on without parsing prose.
  • Login answers identically whether or not the account exists.
  • Nothing in the body describes the server's shape.
  • Leaked — what the caller should never see
    • Names the database host, its private IP, and the port.
    • Reveals the ORM, the driver, and their versions — a version-specific CVE becomes a targeted attempt.
    • The SQL discloses table and column names.
    • "No account with that email" turns login into an account directory.
    • Now stored in the caller's logs, error tracker, and support screenshots.
  • Contained — actionable, and nothing more
    • One generic sentence; the traceback is in the log only.
    • A correlation id joins the caller's report to the exact log line.
    • A stable `type` the client can branch on without parsing prose.
    • Login answers identically whether or not the account exists.
    • Nothing in the body describes the server's shape.

The five things that must never appear in a response body

The five things that must never appear in a response body
Never exposeHow it usually escapesWhere it belongs
Stack traces`DEBUG = True`, or returning `str(exc)`the application log, at `exception` level
SQL statementsan `OperationalError` message passed throughthe log; the database's own slow-query log
Credentialsa connection string inside a driver exceptionnowhere — the log too should mask them
Secrets / API keysan integration error echoing the request it sentthe log, masked, or not logged at all
Sensitive internal statehostnames, private IPs, queue names, other users' rowsthe log and your metrics, never the response

Together

json
{"type": "internal_error",
 "detail": "An unexpected error occurred.",
 "correlation_id": "01J9F2M0Q5X8"}

Remember: An error body says what the caller can act on and nothing about how the server is built: never a stack trace, SQL, a credential, a secret, or internal state. Log all of it with a correlation id and return a generic sentence plus that id. Then close the quieter leak — responses that *differ* when they should not: login and password reset must be identical in body, status, and timing, secrets compare with `compare_digest`, and a 403 becomes a 404 wherever existence is the secret. `DEBUG` must be `False` in production; nothing else on this list matters if it is not.

See also: global exception handlers and stable error payloads · the client error status codes · identity and server side request safety · configuration strategy

Advertisement