Module 5 · Architecture and maintainable code

Lesson 26 — Centralized error handling

One consistent error format for the whole API, not 40 different responses.

Published
In this lesson
  1. Exercise 1 — Taxonomy
  2. Exercise 2 — Complete handler
  3. Exercise 3 — Extra + header
  4. Exercise 4 — The healthy 500
  5. Exercise 5 — assert_problem
  6. Professor's summary

Exercise 1 — Taxonomy

ExceptionStatustypeDiscussed decision
SeatUnavailable409problems/seat-unavailableState conflict: the resource exists, the operation collides with another transaction (10)
ReservationExpired410problems/reservation-expiredThe resource existed and is no longer valid; with Retry-After if the flow allows retrying
EventSoldOut409problems/event-sold-outSame as seat taken: current state vs request
PaymentDeclined402problems/payment-declined402 is semantically exact ("Payment Required"); 409 would work if you prefer the conflict family. The key: ONE documented decision. Many APIs choose 402 with reason in extra
GiftCardInsufficient409problems/gift-card-insufficientIt is not a syntax error of the resource (422) but a state/value issue at that moment; 422 would argue "the entity is valid but the balance isn't". Either is defensible — the mistake is mixing both in the same failure class
Not authenticated401DRF generates it (NotAuthenticated)The handler rewraps it as a problem, type: problems/unauthenticated

Mental rule: 400 the client wrote it wrong; 401/403 who you are/what you may; 404 what you seek isn't there; 409 the current state prevents it; 410 it was there and expired; 422 syntax ok, semantics no; 429 too fast. The 500 never explains the inside.

Exercise 2 — Complete handler

python
# core/exceptions.py
import logging
from rest_framework.views import exception_handler as drf_exception_handler

logger = logging.getLogger(__name__)

BASE = "https://api.ticketflow.dev/problems/"

def problem_response(status, code, title, detail=None, extra=None, headers=None):
    body = {"type": BASE + code, "title": title, "status": status, "detail": detail}
    if extra:
        body.update(extra)
    return Response(body, status=status, headers=headers or {},
                    content_type="application/problem+json")

def problem_handler(exc, context):
    response = drf_exception_handler(exc, context)
    request = context.get("request")

    if response is not None:
        # DRF already decided the status (validation, auth, 404, throttled...)
        view = context.get("view")
        code = getattr(view, "problem_code", None) or default_code(response.status_code)
        data = response.data
        extra = {}
        if isinstance(data, dict) and "detail" not in data:   # per-field validation
            extra = {"errors": data}
            detail = "The request contains invalid data."
        else:
            detail = data.get("detail") if isinstance(data, dict) else str(data)
        body = {"type": BASE + code, "title": default_title(response.status_code),
                "status": response.status_code, "detail": detail, **extra}
        if response.status_code >= 500:
            body["trace_id"] = get_trace_id(request)
        response.data = body
        response.content_type = "application/problem+json"
        return response

    if isinstance(exc, DomainError):
        return problem_response(exc.status, exc.code, exc.title, str(exc),
                                extra=getattr(exc, "extra", None),
                                headers=getattr(exc, "headers", None))

    logger.exception("Unhandled error", extra={"path": getattr(request, "path", "?"),
                                               "trace_id": get_trace_id(request)})
    body = {"type": BASE + "internal-error", "title": "Internal error",
            "status": 500, "detail": "Something went wrong. Retry in a few minutes.",
            "trace_id": get_trace_id(request)}
    return Response(body, status=500, content_type="application/problem+json")

With REST_FRAMEWORK = {"EXCEPTION_HANDLER": "core.exceptions.problem_handler"}. The contract test (2):

python
def test_validacion_sale_como_problem(self):
    res = self.client.post("/api/v1/reservations", data={}, format="json")
    self.assertEqual(res.status_code, 400)
    self.assertEqual(res["Content-Type"], "application/problem+json")
    self.assertTrue(res.json()["type"].endswith("validation-error"))
    self.assertIn("errors", res.json())
  1. The view's migration: the existing response test (200/400 with the same body) passes after deleting the try/except — that test is exactly 28's safety net: a refactor visible only from the inside.

Exercise 3 — Extra + header

python
class SeatUnavailable(DomainError):
    code, status, title = "seat-unavailable", 409, "Seat unavailable"
    def __init__(self, seat_refs):
        super().__init__(", ".join(seat_refs))
        self.extra = {"conflicting_seats": seat_refs}

class ReservationExpired(DomainError):
    code, status, title = "reservation-expired", 410, "The reservation expired"
    def __init__(self, retry_after=30):
        super().__init__(f"Retry in {retry_after} seconds or create a new reservation.")
        self.extra = {"retry_after": retry_after}
        self.headers = {"Retry-After": str(retry_after)}

The handler injects them without knowing them: extra=getattr(exc, "extra", None) and headers=getattr(exc, "headers", None) — 24's polymorphism: the handler doesn't do isinstance per case.

Exercise 4 — The healthy 500

python
def test_500_no_filtra_stack(self):
    with override_settings(DEBUG=False):
        res = self.client.get("/api/v1/boom")
    self.assertEqual(res.status_code, 500)
    self.assertNotIn("ZeroDivisionError", res.content.decode())
    self.assertIn("trace_id", res.json())

The metric in the handler: a single place guarantees ALL 5xx increment it — a middleware skips the flow if an exception happens before it. The 429 with warning (it is an abuse/possible-incident signal, someone reviews it); the 401 without logging (noise: it is the normal "you are not signed in" flow, the client handles it). Rule: what wakes someone at night must deserve it (45/46).

Exercise 5 — assert_problem

python
def assert_problem(res, min_status=400):
    self.assertGreaterEqual(res.status_code, min_status)
    body = res.json()
    self.assertTrue(body["type"].startswith("https://"))
    self.assertTrue(body["title"])
    self.assertEqual(body["status"], res.status_code)
    if res.status_code >= 500:
        self.assertIn("trace_id", body)

Three tests, one composed assertion: when you add a required field tomorrow (instance), a single change. It is the same spirit as 15's contract: the test IS the error contract's documentation.


Professor's summary

  • One format (RFC 7807), one handler, one DomainError taxonomy with status/type per class; the view stops catching.
  • Domain 4xx without stack or alert; 5xx with full log + trace_id + metric and a generic body.
  • assert_problem as an executable contract: the day the format changes, one single test says so.