Errores en la práctica: envolver, propagar y no tragarse nada
Cómo aislar una biblioteca ajena tras una frontera propia, añadir contexto al relanzar sin destruir el mensaje original, limpiar recursos sin bloque final, y qué ocurre exactamente cuando el error nace dentro del manejador de errores.
Las cuatro lecciones anteriores describieron un mecanismo. Esta describe una disciplina, que es otra cosa. En un programa real los errores no llegan del ejemplo de tres funciones anidadas: llegan de una biblioteca de terceros que a veces lanza cadenas y a veces devuelve nulo, de una corrutina que murió hace dos ciclos, de un metamétodo que se ejecutó en un momento que nadie eligió, y de un manejador de errores que falló mientras intentaba explicar el fallo anterior. Lo que sigue es el conjunto de prácticas que separan un sistema en el que un incidente se diagnostica leyendo una línea de registro de otro en el que se diagnostica reproduciendo el estado durante dos días.
- Construir una frontera que normalice el comportamiento errático de una biblioteca ajena.
- Añadir contexto al propagar un fallo sin duplicar posiciones ni perder el mensaje original.
- Liberar recursos cuando un error atraviesa un ámbito, sin disponer de bloque final.
- Anticipar el comportamiento de un error nacido dentro de un manejador, de un finalizador o de una corrutina.
La frontera: envolver y propagar
Una dependencia externa es una fuente de fallos con convenciones que no controlas. Puede lanzar cadenas en unos casos y tablas en otros, devolver nulo sin mensaje, o lanzar valores numéricos que en tu registro aparecerán como cifras sin contexto. La respuesta profesional no es adaptarte a ella en cada punto de uso, sino escribir una capa fina que la normalice una sola vez.
local ajena = require("biblioteca_ajena")
local M = {}
local function normalizar(err)
if type(err) == "table" then return err end
return { clase = "ajena", mensaje = tostring(err) }
end
function M.consultar(clave)
local ok, res = pcall(ajena.query, clave)
if not ok then return nil, normalizar(res) end
if res == nil then return nil, { clase = "ajena", mensaje = "sin resultado" } end
return res
end
return M
La capa cumple tres funciones a la vez: convierte cualquier forma de fallo en una sola, impide que un tipo de la dependencia se filtre al resto del programa, y concentra en un fichero el trabajo del día en que esa dependencia cambie de convención. Ninguna de las tres se consigue capturando errores en cada punto de uso.
Conviene además que la frontera sea estrecha en el sentido literal: cuanto menor sea la superficie de la dependencia que atraviesa tu envoltorio, menos código tuyo dependerá de sus detalles. Un envoltorio que expone cuarenta funciones de la biblioteca ajena porque algún día podrían hacer falta no es una frontera, es una redirección. Envuelve solo lo que usas, y añade lo demás el día que lo necesites.
La segunda mitad del trabajo de frontera es la propagación. Un mensaje que dice que el fichero no existe es inútil si no dice qué fichero ni por qué se estaba abriendo. Añadir esa información al subir es la práctica que más reduce el tiempo de diagnóstico, y tiene una regla técnica concreta: al relanzar, usa nivel cero.
local function cargarPerfil(usuario)
local ok, err = pcall(leerFichero, rutaDe(usuario))
if not ok then
error(string.format("al cargar el perfil de %s: %s", usuario, tostring(err)), 0)
end
end
Sin ese cero, el mensaje resultante llevaría dos posiciones encadenadas: la del error original, que ya venía dentro del texto, y la de la línea donde relanzas, que no aporta nada porque el rastro ya la contiene. Con errores estructurados el equivalente es guardar el error interior en un campo, formando una cadena de causas que el registro final puede recorrer entera.
En cuanto aplicas una conversión a cadena, el error deja de ser inspeccionable y el código de arriba ya no puede ramificar sobre su clase. Propaga la estructura mientras estés dentro de tu programa y conviértela a texto una sola vez, en la frontera exterior donde se escribe el registro.
No tragarse errores
El antipatrón más extendido cabe en una línea, y por eso se cuela en las revisiones de código sin que nadie lo señale.
pcall(guardarEstado) -- si falla, nadie lo sabra jamas
La llamada protegida cuyo resultado se descarta convierte un fallo ruidoso en un silencio, y el silencio es el modo de fallo más caro que existe. Hay exactamente tres razones legítimas para capturar un error, y ninguna de ellas es evitar que el programa se detenga.
Vas a reintentar
Capturas porque tienes una segunda estrategia: otro servidor, otro formato, un valor por defecto. El fallo se registra en el nivel de depuración y el reintento se ejecuta.
Estás en una frontera de aislamiento
Un complemento, una petición, un fotograma. El fallo no debe tumbar el proceso entero, pero se registra completo y la unidad de trabajo se marca como fallida.
Vas a añadir contexto y relanzar
Capturas para enriquecer y devolver hacia arriba. El error sigue subiendo, solo que mejor explicado que antes.
Cualquier otra razón es tragárselo
Si tras capturar no reintentas, no registras y no relanzas, has borrado información del sistema. El día del incidente esa información era la única que había.
Limpiar sin bloque final
Como el desenrollado no ejecuta código de limpieza, un error que atraviesa un ámbito deja abierto lo que hubiera abierto. Hay dos soluciones, y la moderna es claramente superior.
-- Lua 5.4: la variable de cierre se libera salga como salga el bloque
local function procesar(ruta)
local f <close> = assert(io.open(ruta, "r"))
return analizar(f:read("a")) -- si analizar lanza, f se cierra igual
end
-- Compatible con versiones anteriores: capturar, limpiar, relanzar
local function procesarCompat(ruta)
local f = assert(io.open(ruta, "r"))
local ok, res = pcall(analizar, f:read("a"))
f:close()
if not ok then error(res, 0) end
return res
end
La versión compatible es correcta pero frágil: cada retorno anticipado que añadas al cuerpo obliga a repetir el cierre, y basta olvidar uno para tener una fuga. La variable de cierre traslada esa garantía del programador al lenguaje, y es la razón por la que merece la pena exigir Lua 5.4 en proyectos que manejen recursos escasos.
Cuando falla el que maneja los fallos
flowchart TD A[Error dentro del manejador de xpcall] --> B[No se vuelve a llamar al manejador] B --> C[Se devuelve un estado especial y un mensaje fijo] C --> D[Se pierde el diagnostico original] E[Error dentro de un finalizador] --> F[Se convierte en aviso normalmente silenciado] G[Error dentro de un cierre garantizado] --> H[Se propaga con normalidad] I[Error dentro de una corrutina] --> J[Resume devuelve falso y el valor] J --> K[El rastro debe pedirse sobre la corrutina antes de descartarla]
Un error nacido dentro del manejador de una llamada protegida con manejador no se maneja recursivamente: el intérprete corta la secuencia, devuelve un estado de error durante el manejo de errores y sustituye el diagnóstico por un mensaje fijo. Lo que se pierde no es el segundo error, es el primero, que era el importante. Por eso un manejador debe ser defensivo hasta la paranoia: nada de indexar campos sin comprobar, nada de concatenar valores que podrían no ser textuales, nada de llamar a código de la aplicación.
local function manejadorSeguro(err)
local ok, texto = pcall(tostring, err) -- tostring puede lanzar
if not ok then texto = "error no representable" end
local ok2, rastro = pcall(debug.traceback, texto, 2)
return ok2 and rastro or texto
end
Los otros tres casos del diagrama completan el mapa. Un error dentro de un finalizador se convierte en un aviso, y el sistema de avisos nace apagado, de modo que puede estar fallando en cada ejecución sin que nadie lo note. Un error dentro de un cierre garantizado sí se propaga, porque ese código forma parte del flujo normal. Y un error dentro de una corrutina no cruza la frontera de la reanudación: se devuelve como valor, y si quieres el rastro tienes que pedirlo sobre el objeto de la corrutina antes de que se descarte, porque su pila es independiente de la tuya.
La medida real de la calidad del manejo de errores de un programa no es cuántos fallos evita ni con qué elegancia se recupera, sino cuánto puede reconstruir del incidente una persona que no estaba presente, que no puede reproducirlo y que solo dispone de lo que quedó escrito. Bajo ese criterio, casi todas las decisiones de este nivel se reordenan. Capturar un error y continuar sin registrarlo deja de ser una optimización de robustez y pasa a ser una destrucción de pruebas. Relanzar sin contexto deja de ser neutro y pasa a ser una oportunidad desperdiciada: cada capa que un error atraviesa conoce un dato que las de arriba nunca podrán deducir —qué usuario, qué ruta, qué reintento, qué configuración— y si esa capa no lo adjunta, ese dato desaparece para siempre en el instante en que el marco se desenrolla. Convertir el error a texto en la capa equivocada deja de ser una cuestión de gusto y pasa a ser la razón por la que arriba nadie puede distinguir un fallo transitorio de uno permanente, y por tanto la razón por la que el reintento automático que alguien escribió con buena fe reintenta durante horas algo que jamás va a funcionar. El hilo que une todo esto es que la información sobre un fallo tiene una vida útil brevísima y decreciente: es máxima en el marco donde ocurre, es alta en el manejador que corre antes del desenrollado, es media en la frontera del módulo, y es prácticamente nula en el registro final si nadie la fue acumulando por el camino. Diseñar el manejo de errores consiste, casi por completo, en decidir en qué puntos de esa curva descendente vas a detenerte a escribir lo que todavía sabes.
- Envuelve una biblioteca que uses habitualmente tras una capa que normalice todos sus fallos a una sola forma estructurada.
- Encadena tres capas que añadan contexto al relanzar y comprueba que el mensaje final no contiene posiciones duplicadas.
- Escribe un manejador que falle al intentar formatear el error y observa qué mensaje llega arriba. Después conviértelo en defensivo y repite.
- Provoca un error dentro de una corrutina y recupera su rastro de pila real, no el del hilo que la reanudó.
- Busca en tu código toda llamada protegida cuyo resultado se descarte y clasifícala en una de las tres razones legítimas. Corrige las que no encajen en ninguna.