34  25. Necesitamos que los errores escalen como en un equipo real

El frontend no puede programar contra el caos

NotaEn una frase

El cap. 6 te enseñó el formato {error:{code}}. Ahora el problema es de escala: 40 endpoints, errores de BD, de dominio, de terceros — si cada uno inventa su traducción, el frontend programa contra el caos. La solución es una jerarquía de errores de dominio + un handler global que es la única frontera donde un error se convierte en HTTP.

34.1 El problema

users devuelve {message: "nope"}, tasks devuelve un stack trace, projects devuelve 200 con {error: null, data: null} cuando algo falla. El frontend necesita un if por endpoint — y el día que cambias un texto (“not found” → “no encontrado”), un if (err.message === "not found") de alguien explota en producción. Los mensajes son para humanos; las máquinas necesitan códigos estables.

null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.

return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.

Producción (prod) es el entorno real: donde están los usuarios, los datos que importan y las consecuencias. Todo lo demás — local, staging — existe para que los errores ocurran antes de llegar ahí.

34.2 Cómo lo resuelve un equipo

Un equipo serio separa dos mundos con una frontera clara:

  • Dominio: el servicio lanza TaskNotFoundError, ForbiddenError, ValidationError — errores que hablan el idioma del negocio y no saben nada de HTTP.
  • Transporte: un handler global (middleware de error / exception handler / mapper central) traduce cada error de dominio a su status + código estable. Es el único lugar donde vive esa tabla.

Un dominio es el nombre que compras (midominio.com) y apuntas a tu servidor por DNS. Sin él, tus usuarios tendrían que memorizar una IP. El HTTPS serio requiere dominio.

El contrato hacia afuera: {"error": {"code": "TASK_NOT_FOUND", "message": "..."}} — el code es API (estable, testeable), el message es texto (puede cambiar, traducirse).

34.3 Conceptos nuevos

  • U Jerarquía de errores de dominio: DomainError → NotFoundError → TaskNotFoundError
  • TS Clases extends Error · Py Excepciones propias · Go Sentinel errors + errors.Is/As
  • U Handlers globales: la frontera entre “error de negocio” y “respuesta HTTP”
  • U El contrato de error: {"error": {"code": "TASK_NOT_FOUND"}} — códigos estables vs mensajes cambiantes
  • U 500: qué se devuelve (poco) vs qué se loguea (todo)

34.4 La explicación visual

flowchart LR
  S[Servicio] -->|throw TaskNotFoundError| H{Handler global}
  D[Driver BD] -->|pgx.ErrNoRows| S
  H -->|404| C["{error:{code:'TASK_NOT_FOUND'}}"]
  X[Cualquier panic/error inesperado] -->|500 genérico| C
  X -->|stack trace completo| L[logs — solo aquí]

El servicio lanza significado; el handler traduce; el cliente ve contrato; el detalle feo queda en logs.

Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.

34.5 Implementación

Jerarquía + mapper central en los tres stacks:

export class DomainError extends Error {
  constructor(public code: string, message: string, public status = 400) {
    super(message);
  }
}
export class NotFoundError extends DomainError {
  constructor(what: string) { super(`${what.toUpperCase()}_NOT_FOUND`, `${what} not found`, 404); }
}

// the ONLY translation point — registered last
app.use((err, req, res, next) => {
  if (err instanceof DomainError) {
    return res.status(err.status).json({ error: { code: err.code, message: err.message } });
  }
  logger.error({ err, request_id: req.id }, "unhandled error");   // everything goes to logs
  res.status(500).json({ error: { code: "INTERNAL", message: "internal error" } });
});
// service: throw new NotFoundError("task") — knows no HTTP
class DomainError(Exception):
    code = "DOMAIN"
    status = 400

class NotFoundError(DomainError):
    status = 404
    def __init__(self, what: str):
        self.code = f"{what.upper()}_NOT_FOUND"
        super().__init__(f"{what} not found")

@app.exception_handler(DomainError)
async def domain_handler(request, exc: DomainError):
    return JSONResponse({"error": {"code": exc.code, "message": str(exc)}},
                        status_code=exc.status)

@app.exception_handler(Exception)
async def unhandled_handler(request, exc: Exception):
    logger.exception("unhandled", extra={"ctx": {"request_id": request.state.request_id}})
    return JSONResponse({"error": {"code": "INTERNAL", "message": "internal error"}},
                        status_code=500)
var ErrNotFound = errors.New("not found")

type DomainError struct {
    Code    string
    Message string
    Status  int
}
func (e *DomainError) Error() string { return e.Message }

func writeErr(w http.ResponseWriter, err error) {
    var de *DomainError
    switch {
    case errors.Is(err, pgx.ErrNoRows):                    // driver error → domain
        de = &DomainError{"TASK_NOT_FOUND", "task not found", 404}
    case errors.As(err, &de):
        // already a domain error
    default:
        slog.Error("unhandled", "err", err)
        de = &DomainError{"INTERNAL", "internal error", 500}
    }
    writeJSON(w, de.Status, map[string]any{
        "error": map[string]string{"code": de.Code, "message": de.Message}})
}
Implementa un sistema de errores coherente para mi API
[Express/FastAPI/net-http]. Requisitos: jerarquía DomainError con
status+code (NotFoundError, ValidationError, ForbiddenError,
UnauthorizedError, ConflictError, ExternalServiceError), un handler
global que sea la ÚNICA frontera dominio→HTTP y devuelva siempre
{"error":{"code","message"}} más request_id en logs para errores
inesperados, el 500 genérico sin stack trace en la respuesta, y tests
que verifiquen que NINGÚN endpoint filtra stack trace ni devuelve otro
formato.

34.6 ¿Por qué cada stack lo hace así?

Lo único que necesitas llevarte: los tres hacen lo mismo — errores que significan algo en el dominio, un solo lugar que los traduce a HTTP, y un contrato estable para el cliente. Cambia el mecanismo (herencia vs excepciones vs sentinel errors), no el diseño.

  • TS: clases extends Error + instanceof — la jerarquía es literal (NotFoundError es un DomainError).
  • Python: excepciones con atributos de clase + un handler por tipo — FastAPI elige el handler por la clase de la excepción.
  • Go: sin excepciones — el error es un valor que viaja por return; errors.Is/As pregunta “¿es este tipo?” y writeErr es el mapper manual. Más verboso, más explícito: el error está en la firma.

RFC 9457 (problem details) es la alternativa “estándar” al contrato propio: {"type","title","status","detail"} — misma idea, vocabulario IETF. Lo que importa no es el formato elegido sino que sea uno solo y estable.

34.7 Errores comunes

Error Por qué pasa Fix
if (err.message === "task not found") en el cliente Los mensajes eran la API Códigos estables: code es contrato, message es decoración
Stack trace en el body del 500 El framework lo hacía en dev Handler global: al cliente “INTERNAL”, al log todo
Errores de BD burbujeando al handler pgx.ErrNoRows llega crudo El servicio traduce driver→dominio en su frontera
Status 200 con error dentro “Para no romper el frontend” El status es parte del contrato — 4xx/5xx existen por algo
Handler de error por endpoint Copia pega del try/catch UN handler global + errores de dominio que viajan solos

34.8 Buenas prácticas

  • Catálogo de códigos: TASK_NOT_FOUND, VALIDATION, UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, CONFLICT, EXTERNAL_SERVICE, INTERNAL — documentados, estables, testeados. Son API pública: se agregan, no se renombran.
  • El 500 no improvisa: mismo formato que el resto — el cliente no tiene un parser para “cuando todo explota”.
  • Errores de dominio pequeños y específicos: NotFoundError con el qué — el código sale solo (TASK_NOT_FOUND), sin decidirlo en cada throw.
  • El handler global también loguea: request_id + error completo + contexto (cap. 23) — el cliente ve poco, tú ves todo.

34.9 Ejercicio

  1. ¿Por qué message no puede ser el contrato aunque sea “estable hoy”? Da dos razones.
  2. El driver lanza pgx.ErrNoRows. ¿Quién lo traduce a TASK_NOT_FOUND — el storage, el servicio o el handler global? Defiende tu respuesta.
  3. Agrega ConflictError (409, para email duplicado) a la jerarquía: ¿qué code debería producir POST /auth/register con email ya tomado y por qué no 422?

La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.

34.10 Mini reto

Garantizar que ningún endpoint filtra un stack trace — con tests.

Ejercicio.

    1. Los mensajes cambian: traducción, mejora de copy, un typo — cada cambio es un breaking change invisible. (b) Las máquinas comparan strings con edge cases (espacios, mayúsculas); un code es un identificador diseñado para compararse.
  1. El servicio — es su frontera: el storage reporta “no había fila” (hecho técnico), el servicio decide “eso significa task no encontrada” (significado de negocio). El handler global solo ve errores de dominio ya traducidos. Si el driver llega al handler crudo, cambiar de Postgres a MySQL cambiaría tu API pública.
  2. {"error":{"code":"EMAIL_TAKEN","message":"email already registered"}} con 409 — no es un body malformado (422 sería “tu JSON está mal”), es un conflicto de estado: el recurso ya existe. 409 es el status honesto para eso.

Mini reto. Un test que pega a cada endpoint con inputs que exploten (DB caída, error forzado) y verifica: status 500 y body parsea como {error:{code:"INTERNAL"}} y no contiene “at”, “Traceback”, “.go:”, ni “node_modules”. El test es la garantía permanente — si alguien rompe el handler global, este test lo grita antes que prod.

34.11 Vocabulario técnico del capítulo

Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.

Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.

Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.

Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.

Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.

Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.

Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.

El estado es todo lo que el programa recuerda: las variables, la sesión, lo que muestra la UI. Stateless (sin estado) = el servidor no recuerda nada entre requests — cada request trae todo lo necesario.

Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.

Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.

Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.

Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.

Vertical: máquina más gorda (más CPU/RAM) — fácil, tiene techo. Horizontal: más máquinas — el camino de internet, pero requiere que la app no guarde estado local (por eso todo va a BD/Redis).

Un recurso es la “cosa” que tu API maneja: tasks, users, projects. Las URLs los nombran (/tasks/5), los verbos actúan sobre ellos. Diseñar REST = nombrar bien tus recursos.

34.12 Lo que deberías saber hacer ahora