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. Objetivos
  2. 1. El problema: 40 formatos de error
  3. 2. La pirámide de excepciones
  4. 3. El handler central en DRF
  5. 4. Custom exceptions con payload extra
  6. 5. Qué registrar y qué no
  7. Autoevaluación

Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — el contrato de errores de toda la API Prerrequisito: Lección 25 — Patrones de servicio


Objetivos

  1. Unificar TODOS los errores de la API en un formato único (RFC 7807 application/problem+json), con una sola pieza de código que los traduzca.
  2. Encadenar excepciones de dominio → infraestructura → HTTP sin que cada vista repita try/except.
  3. Registrar lo que importa (con IDs de correlación, 45) y no registrar lo que no (payloads con PII, 23).

1. El problema: 40 formatos de error

Sin un handler central, cada vista decide su formato y en el mismo ticket obtienes: {"error": "not found"} en una ruta, {"detail": "No existe"} (DRF default) en otra, un HTML de 500 en la tercera, y un 200 con {"ok": false} en la peor. El cliente no puede programar contra eso. La regla: un formato para todos los errores, decidido una vez. La 13 ya eligió RFC 7807; esta lección lo implementa de punta a punta.

json
{
  "type": "https://api.ticketflow.dev/problems/seat-unavailable",
  "title": "El asiento ya no está disponible",
  "status": 409,
  "detail": "El asiento A12 fue reservado por otra persona mientras completabas la compra.",
  "instance": "/api/v1/reservations",
  "trace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

type es un URI estable que el cliente puede documentar; title es genérico (no cambie entre peticiones); detail es específico; instance identifica la petición; trace_id engancha con los logs (45). Content-Type: application/problem+json.

2. La pirámide de excepciones

Tres niveles, cada uno con su vocabulario:

python
# Dominio (servicios/modelos): reglas de negocio violadas
class DomainError(Exception):
    code = "domain-error"; status = 400; title = "Petición inválida"

class SeatUnavailable(DomainError):
    code = "seat-unavailable"; status = 409; title = "Asiento no disponible"

class ReservationExpired(DomainError):
    code = "reservation-expired"; status = 410; title = "La reserva expiró"

# Infraestructura: el mundo exterior falló
class GatewayTimeout(Exception): ...
class BrokerUnavailable(Exception): ...

# HTTP: solo en la capa de vista (o el handler central)

El servicio lanza SeatUnavailable (dominio). La vista NO lo captura: deja que suba. El handler central lo traduce a problem+json con su status. La vista solo captura lo que decide hacer de forma distinta a la regla general — y eso es raro.

3. El handler central en DRF

DRF ya centraliza los errores de serializador/auth con EXCEPTION_HANDLER; extiéndelo cubre el resto:

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

def problem_handler(exc, context):
    response = drf_exception_handler(exc, context)   # cubre Http404, PermissionDenied, APIException, validación
    if response is not None:                          # DRF lo knows: formatea como problem
        response.data = to_problem(exc, response.status_code, response.data)
        return response
    if isinstance(exc, DomainError):                  # dominio: el traductor nuevo
        return problem_response(exc.status, exc.code, exc.title, str(exc))
    logger.exception("unhandled", extra={"trace_id": get_trace_id()})   # 500: lo raro se registra
    return problem_response(500, "internal-error", "Error interno", None)

Con settings.EXCEPTION_HANDLER = "core.exceptions.problem_handler" y settings.DEBUG fuera, Django también renderiza 500 como JSON vía middleware si la ruta lo requiere. Claves de implementación: las 4xx del dominio NO generan traceback ni alerta (son negocio, no bugs); los 500 SÍ (log con logger.exception + alerta, 46); instance se rellena con request.path; el trace_id lo pone el middleware de correlación (45) — el handler solo lo lee.

4. Custom exceptions con payload extra

A veces el cliente necesita más que title/detail: los asientos en conflicto, el reintento tras X segundos.

python
class SeatUnavailable(DomainError):
    code, status, title = "seat-unavailable", 409, "Asiento no disponible"

    def __init__(self, seat_refs: list[str]):
        super().__init__(f"Asientos no disponibles: {', '.join(seat_refs)}")
        self.extra = {"conflicting_seats": seat_refs}

# el handler serializa exc.extra dentro del problem:
{"type": ".../seat-unavailable", "status": 409, "conflicting_seats": ["A12", "B3"]}

Regla del payload extra: datos que el cliente usa para ACTUAR (reintentar con otros asientos), nunca internal state (nombres de tabla, SQL, stack). El detail es para humanos; type+extra, para el código del cliente.

5. Qué registrar y qué no

En el handler pasa el filtro de la 23/45: el detail de validación puede contener lo que el usuario escribió (PII) — al log va el type+trace_id+path, no el payload. Los 500 con logger.exception (stack completa al log, nunca al body de la respuesta — el body 500 es genérico y no filtra). Cada 5xx lleva el trace_id y entra en la métrica http_5xx_total (46): el handler es también el punto donde nace la observabilidad del error.

Señal de éxito: greps que ya no encuentran return Response({"error":...}) en vistas — todo error pasa por el handler y por DomainError.


Autoevaluación

  1. ¿Por qué el mismo ticket no puede convivir con 3 formatos de error y qué contrato fija RFC 7807?
  2. Dibuja la pirámide: ¿quién lanza DomainError, quién lo traduce, y qué capa NO debe capturarlo?
  3. ¿Qué cubre el exception_handler por defecto de DRF y qué añade el tuyo?
  4. ¿Cuándo una excepción lleva extra en el problem y qué NUNCA va ahí?
  5. ¿Qué se registra en un 500 y qué en un 409 de dominio? ¿Dónde acaba el traceback?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.