Ejercicio 1 — Taxonomía
| Excepción | Status | type | Decisión discutida |
|---|---|---|---|
SeatUnavailable | 409 | problems/seat-unavailable | Conflicto de estado: el recurso existe, la operación choca con otra transacción (10) |
ReservationExpired | 410 | problems/reservation-expired | El recurso existía y ya no es válido; con Retry-After si el flujo permite reintentar |
EventSoldOut | 409 | problems/event-sold-out | Igual que asiento ocupado: estado actual vs petición |
PaymentDeclined | 402 | problems/payment-declined | 402 es semánticamente exacto ("Payment Required"); 409 valdría si prefieres la familia de conflicto. La clave: UNA decisión documentada. Muchas APIs eligen 402 con reason en extra |
GiftCardInsufficient | 409 | problems/gift-card-insufficient | No es un error de sintaxis del recurso (422) sino de estado/valor en el momento; 422 defendería "la entidad es válida pero el saldo no". Cualquiera es defendible — el error es mezclar ambos en la misma clase de fallo |
| No autenticado | 401 | DRF lo genera (NotAuthenticated) | El handler lo reenvuelve como problem, type: problems/unauthenticated |
Regla mental: 400 el cliente escribió mal; 401/403 quién eres/qué puedes; 404 qué buscas no está; 409 estado actual lo impide; 410 estaba y caducó; 422 sintaxis ok, semántica no; 429 demasiado rápido. El 500 nunca explica el interior.
Ejercicio 2 — Handler completo
# 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 ya decidió status (validación, 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: # validación por campos
extra = {"errors": data}
detail = "La petición contiene datos inválidos."
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": "Error interno",
"status": 500, "detail": "Ha ocurrido un error. Reintenta en unos minutos.",
"trace_id": get_trace_id(request)}
return Response(body, status=500, content_type="application/problem+json")Con REST_FRAMEWORK = {"EXCEPTION_HANDLER": "core.exceptions.problem_handler"}. El test de contrato (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())- La migración de la vista: el test de respuesta existente (200/400 con el mismo body) pasa tras borrar el try/except — ese test es exactamente la red de la 28: refactor visible solo por el interior.
Ejercicio 3 — Extra + header
class SeatUnavailable(DomainError):
code, status, title = "seat-unavailable", 409, "Asiento no disponible"
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, "La reserva expiró"
def __init__(self, retry_after=30):
super().__init__(f"Reintenta en {retry_after} segundos o crea una nueva reserva.")
self.extra = {"retry_after": retry_after}
self.headers = {"Retry-After": str(retry_after)}El handler los inyecta sin conocerlos: extra=getattr(exc, "extra", None) y headers=getattr(exc, "headers", None) — polimorfismo de la 24: el handler no hace isinstance por caso.
Ejercicio 4 — 500 sano
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())La métrica en el handler: un solo lugar garantiza que TODOS los 5xx la incrementan — un middleware se salta el flujo si hay excepción antes de él. El 429 con warning (es señal de abuso/posible incidente, la revisan); el 401 sin log (ruido: es el flujo normal de "no has iniciado sesión", el cliente lo maneja). Regla: lo que despierta a alguien de noche debe merecerlo (45/46).
Ejercicio 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)Tres tests, una assertion compuesta: cuando mañana añadas un campo obligatorio (instance), un solo cambio. Es el mismo espíritu que el contrato de la 15: el test ES la documentación del contrato de errores.
Resumen del profesor
- Un formato (RFC 7807), un handler, una taxonomía de DomainError con status/type por clase; la vista deja de capturar.
- 4xx de dominio sin stack ni alerta; 5xx con log completo + trace_id + métrica y body genérico.
assert_problemcomo contrato ejecutable: el día que cambie el formato, un solo test lo dice.