Exercise 1 — Taxonomy
| Exception | Status | type | Discussed decision |
|---|---|---|---|
SeatUnavailable | 409 | problems/seat-unavailable | State conflict: the resource exists, the operation collides with another transaction (10) |
ReservationExpired | 410 | problems/reservation-expired | The resource existed and is no longer valid; with Retry-After if the flow allows retrying |
EventSoldOut | 409 | problems/event-sold-out | Same as seat taken: current state vs request |
PaymentDeclined | 402 | problems/payment-declined | 402 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 |
GiftCardInsufficient | 409 | problems/gift-card-insufficient | It 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 authenticated | 401 | DRF 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
# 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):
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())- 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
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
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
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_problemas an executable contract: the day the format changes, one single test says so.