flowchart TB
subgraph Domain["Capa de negocio (no sabe de HTTP)"]
Svc["service.getTask(99)"] -->|"no existe"| Err["Err: TaskNotFound"]
end
subgraph Edge["Frontera HTTP (no sabe de negocio)"]
Err --> Map{"Error mapper"}
Map -->|"TaskNotFound"| R404["404 + { error.code }"]
Map -->|"ValidationError"| R422["422 + detalles"]
Map -->|"cualquier otro"| R500["500 + log interno"]
end
15 6. Necesitamos fallar correctamente
Tres lenguajes, tres filosofías de error
Vas a aprender a fallar bien: un formato de error único para el cliente y una frontera que traduce errores de negocio a HTTP — y de paso verás la diferencia más real entre los tres lenguajes: excepciones vs errores como valores.
15.1 El problema
GET /tasks/99 cuando solo existen 5 tareas. ¿Qué devuelves?
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”.
- ¿
null? — el frontend hacetask.titley explota. - ¿
200 {"error": "not found"}? — elres.okdefetchdice true y tu React pinta una tarea fantasma. - ¿Dejar que
tasks[99]reviente con un stack trace? — el cliente ve las tripas de tu servidor (y un atacante también).
Fallar es inevitable. Fallar bien es diseño: el error es parte del contrato, igual que la respuesta exitosa. Y aquí nos encontramos con la primera divergencia filosófica real entre los tres lenguajes.
15.2 Cómo lo resuelve un equipo
Un equipo maduro decide tres cosas sobre los errores:
La forma: un formato único para todos los errores — el frontend escribe una función
handleError(res)y funciona para siempre:{ "error": { "code": "TASK_NOT_FOUND", "message": "Task 99 does not exist" } }Y esto es lo que nunca debe ver el cliente — pero sí tu log:
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.
Cliente ve: 500 { "error": { "code": "INTERNAL", "message": "unexpected error" } }
Log guarda: Traceback … line 42, in get_task … sqlite3.OperationalError:
database is locked (request_id=req_8f3a)
La frontera: los errores de negocio (“la tarea no existe”) viajan como valores normales hasta la capa de transporte, que los convierte en HTTP. El servicio no sabe de status codes; el router no sabe de negocio.
Los códigos estables:
TASK_NOT_FOUNDes contrato — el mensaje puede cambiar, el código no. El frontend programa contra el código.
15.3 Conceptos nuevos
- U Error de dominio vs error de sistema: “tarea no existe” (esperado, 404) vs “se cayó la BD” (inesperado, 500). Se manejan distinto y se reportan distinto.
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.
- TS Excepciones:
throw/catch, más el error middleware de Express como frontera HTTP. - Py Excepciones:
raise/except,HTTPExceptiony los exception handlers globales. - Go Error como valor: no hay excepciones — las funciones devuelven
(result, error)y tú decides. El modelo más explícito, y el que mejor enseña el concepto.
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.
- U Jerarquía de errores:
DomainError → NotFoundError → TaskNotFoundError— clasificar para responder.
15.4 La explicación visual
La línea divisoria es la regla de oro: los errores nacen en el dominio como datos del dominio, y se traducen a HTTP en el borde. Si tu servicio devuelve HTTPException o res.status(), acabas de acoplar la lógica al transporte — y mañana no podrás reusarla desde un WebSocket o un test.
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.
WebSocket es una conexión que queda viva: en vez de preguntar- responder-cerrar como HTTP, ambos pueden hablar cuando quieran. Es como el servidor puede empujar — por eso sirve para chat y colaboración en vivo.
15.5 Implementación
// errores de dominio
class DomainError extends Error {}
class NotFoundError extends DomainError {
constructor(public code: string, msg: string) { super(msg); }
}
// servicio: lanza errores de dominio, no sabe de HTTP
function getTask(id: number): Task {
const t = tasks.find((t) => t.id === id);
if (!t) throw new NotFoundError("TASK_NOT_FOUND", `Task ${id} does not exist`);
return t;
}
// handler: delgado — la traducción a HTTP vive en el middleware de error
app.get("/tasks/:id", (req, res, next) => {
try {
res.json(toPublic(getTask(Number(req.params.id))));
} catch (e) { next(e); }
});
// la frontera: un solo lugar que traduce errores → HTTP
app.use((err: Error, _req, res, _next) => {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: { code: err.code, message: err.message } });
}
console.error(err); // el 500 se loguea completo, se devuelve poco
res.status(500).json({ error: { code: "INTERNAL", message: "unexpected error" } });
});// neverthrow — errores como VALORES (estilo Go/Rust) dentro de TS
import { Result, ok, err } from "neverthrow";
function getTask(id: number): Result<Task, NotFoundError> {
const t = tasks.find((t) => t.id === id);
return t ? ok(t) : err(new NotFoundError("TASK_NOT_FOUND", "…"));
}
// en el handler: result.match(t => res.json(t), e => next(e))Qué te cuesta: Result hace el error visible en la firma (como Go), pero es una convención de librería — tu equipo entero debe adoptarla. Interesante saber que existe: es el modelo de Go empaquetado para TS.
# errores de dominio
class DomainError(Exception): ...
class NotFoundError(DomainError):
def __init__(self, code: str, message: str):
self.code, self.message = code, message
# servicio: lanza errores de dominio
def get_task(task_id: int) -> Task:
for t in tasks:
if t.id == task_id:
return t
raise NotFoundError("TASK_NOT_FOUND", f"Task {task_id} does not exist")
# la frontera: exception handler global — el handler ni try/except necesita
@app.exception_handler(NotFoundError)
def not_found_handler(_req, exc: NotFoundError):
return JSONResponse(
status_code=404,
content={"error": {"code": exc.code, "message": exc.message}},
)
@app.get("/tasks/{task_id}", response_model=TaskPublic)
def get_task_endpoint(task_id: int):
return get_task(task_id) # si falla, el handler global traduce# La alternativa "errores como valores" en Python (librería result/returns):
from result import Result, Ok, Err
def get_task(task_id: int) -> Result[Task, NotFoundError]:
...
return Err(NotFoundError("TASK_NOT_FOUND", "…"))Qué te cuesta: result/returns traen el modelo explícito de Go a Python — pero pelean contra la idiomática del lenguaje (todo el mundo espera excepciones). Y en el formato del error existe un estándar: RFC 9457 application/problem+json — lo verás en APIs grandes.
// errores de dominio como valores — no hay excepciones
var ErrTaskNotFound = errors.New("task not found")
// servicio: devuelve (resultado, error) — explícito siempre
func getTask(id int) (Task, error) {
for _, t := range tasks {
if t.ID == id {
return t, nil
}
}
return Task{}, fmt.Errorf("%w: %d", ErrTaskNotFound, id)
}
// handler: la traducción es visible en cada endpoint (o un helper writeError)
func getTaskHandler(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
t, err := getTask(id)
if err != nil {
if errors.Is(err, ErrTaskNotFound) {
writeError(w, 404, "TASK_NOT_FOUND", err.Error())
return
}
log.Println("internal:", err)
writeError(w, 500, "INTERNAL", "unexpected error")
return
}
json.NewEncoder(w).Encode(toPublic(t))
}// Go SÍ tiene "excepciones": panic. Pero es para bugs irrecuperables,
// no para errores de negocio — un panic en un handler derriba el request.
// net/http hace recover por request, pero usarlo como flujo de control
// es el equivalente a "throw para todo": se considera malpractice.Qué te cuesta: panic existe pero su rol es distinto — “esto no debería pasar jamás” (índice fuera de rango, invariante roto). Los errores esperados viajan como valores. El modelo de Go te obliga a esta distinción que otros lenguajes dejan difusa.
Implementa el manejo de errores de mi API en
[Express+TypeScript / FastAPI+Python / Go]. Requisitos: jerarquía de
errores de dominio (DomainError → NotFoundError con code estable tipo
TASK_NOT_FOUND), el servicio lanza errores de dominio sin conocer HTTP,
UN solo lugar traduce errores a HTTP (middleware / exception handler /
helper), formato de respuesta { error: { code, message } } consistente,
y el 500 loguea el stack completo pero devuelve solo {code: "INTERNAL"}
— nunca el trace al cliente.
15.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: TS y Python usan excepciones (el error salta a la frontera); Go los devuelve como valores (la firma grita que puede fallar). Mismo objetivo, filosofías opuestas. El detalle:
- TypeScript y Python comparten el modelo de excepciones: el error salta de la capa profunda a la frontera sin que los niveles intermedios lo mencionen. Cómodo — pero la firma de
get_task()no dice que puede fallar; tienes que saberlo. - Go rechaza las excepciones a propósito:
getTask(id)devuelve(Task, error)y la firma grita que puede fallar. Cada llamada exigeif err != nil— verborrea real, pero también cero errores invisibles viajando por capas. Es la diferencia filosófica más honesta del libro: ¿prefieres errores que emergen solos o errores que declaras a mano?
errors.Is/errors.As merece una mirada: envolver con %w crea una cadena ("task not found: 99") que el borde puede interrogar sin parsear strings — el equivalente Go a la jerarquía de clases de error.
Esta decisión es material: devolver un formato consistente es estructural (el frontend programa contra él); cuál formato es intercambiable.
| Alternativa | Qué es | Qué te cuesta | Cuándo elegirla |
|---|---|---|---|
| Envelope propio (este libro) | { error: { code, message, details } } |
Diseñarlo y mantenerlo tú | APIs internas/propias — el default pragmático |
RFC 9457 application/problem+json |
Estándar: type, title, status, detail |
Media type + campos fijos | APIs públicas, interoperabilidad |
| Errores por campo estilo formulario | { fields: { title: ["required"] } } |
Menos expresivo fuera de forms | APIs que solo sirven formularios |
| GraphQL errors array | { errors: [{ message, extensions }] } |
Acoplado a GraphQL | Si ya eres GraphQL |
15.7 Errores comunes
- El
200mentiroso: devolver{"error": ...}con status 200 (ya sabes por quéres.oklo empeora). HTTPExceptiondentro del servicio (Py) ores.status()en la lógica (TS): acoplas dominio a transporte — el mismo error no sirve para un CLI, un test o un WebSocket.
Un CLI (Command Line Interface) es un programa que se usa escribiendo comandos en la terminal: git, npm, docker son CLIs. Lo contrario es una GUI (interfaz gráfica con botones).
try/exceptque traga todo:except Exception: passconvierte un bug en un misterio. Captura lo que esperas; deja escapar lo demás.- Devolver el stack trace al cliente: el 500 se loguea entero en el servidor y se devuelve como
{ "code": "INTERNAL" }— nunca las tripas. - En Go, ignorar el error:
t, _ := getTask(id)compila y es una bomba.errchecken CI lo caza.
15.8 Buenas prácticas
- Un formato de error, para siempre:
{ error: { code, message, details? } }desde el Cap. 6 — y nunca cambiarlo. - Códigos estables en SCREAMING_SNAKE: el frontend los switchea.
- Los errores esperados no se loguean como ERROR: un 404 por id inexistente es INFO/WARN — el log de ERROR es para lo que requiere despertar a alguien.
- El middleware/handler de errores se registra una vez y cubre todos los endpoints — consistente por construcción.
15.9 Ejercicio
- Implementa la jerarquía
DomainError → NotFoundErroren tu stack y el mapper de la frontera. - Agrega
ConflictError(409) para “no se puede borrar una tarea con subtareas pendientes” — y su mapeo. - Provoca un 500 a propósito y verifica: el cliente ve solo
{ code: "INTERNAL" }y el log tiene el stack completo.
15.10 Mini reto
Simula un error inesperado en plena creación de tarea (por ejemplo, la “BD” en memoria lanzando una excepción rara). Verifica que el cliente nunca ve el stack trace y que el log del servidor sí lo tiene. Bonus: ¿cómo harías que el error devuelva un request_id que el usuario pueda reportar? (El Cap. 23 lo implementa de verdad.)
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Ejercicio 1. NotFoundError con code y message + mapper: TS en el error middleware (err instanceof NotFoundError → 404), Py en @app.exception_handler, Go con errors.Is(err, ErrTaskNotFound) → writeError(w, 404, …).
Ejercicio 2. ConflictError mapea a 409 igual que NotFound a 404 — misma frontera, otra entrada del mapper. La regla de negocio (“no borrar con subtareas”) vive en el servicio, no en el handler.
Ejercicio 3. Provocar el 500 (p. ej. raise RuntimeError("boom") en el servicio): el cliente recibe solo {"error":{"code":"INTERNAL",…}} y el log muestra el stack completo — eso es lo correcto.
Mini reto. El request_id se genera en un middleware al inicio del request (UUID corto), se guarda en el contexto (res.locals / context.Context / request state), se incluye en TODOS los logs del request y se devuelve en el body del error. El usuario reporta “me dio INTERNAL req_8f3a” y tú buscas ese id en los logs — Cap. 23 lo implementa.
15.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.
Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.
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.
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.
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 compilador traduce código a otra forma: TypeScript → JavaScript (transpila), Go → binario (compila). Atrapa errores antes de ejecutar. tsc, esbuild, go build son compiladores.
Un índice es la tabla de contenido de una tabla: sin él, buscar un usuario es leer las 10 millones de filas (seq scan); con él, es ir directo a la página. Se crea según cómo se consulta, y cada uno cuesta escrituras.
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.
15.12 Lo que deberías saber hacer ahora
El parámetro es el hueco declarado en la función (def f(x) — x es parámetro); el argumento es el valor concreto que le pasas (f(42) — 42 es argumento). Mismo dato, dos momentos: declaración vs uso.