Módulo 5 · Arquitectura y código mantenible

Lección 26 — Manejo de errores centralizado

Un formato de error consistente para toda la API, no 40 respuestas distintas.

Publicada
En esta lección
  1. Ejercicio 1 — Taxonomía
  2. Ejercicio 2 — Handler completo
  3. Ejercicio 3 — Extra + header
  4. Ejercicio 4 — 500 sano
  5. Ejercicio 5 — assert_problem
  6. Resumen del profesor

Ejercicio 1 — Taxonomía

ExcepciónStatustypeDecisión discutida
SeatUnavailable409problems/seat-unavailableConflicto de estado: el recurso existe, la operación choca con otra transacción (10)
ReservationExpired410problems/reservation-expiredEl recurso existía y ya no es válido; con Retry-After si el flujo permite reintentar
EventSoldOut409problems/event-sold-outIgual que asiento ocupado: estado actual vs petición
PaymentDeclined402problems/payment-declined402 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
GiftCardInsufficient409problems/gift-card-insufficientNo 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 autenticado401DRF 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

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 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):

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. 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

python
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

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())

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

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)

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_problem como contrato ejecutable: el día que cambie el formato, un solo test lo dice.