← Volver al listado de tecnologías

Capítulo 17: Manejo de Errores

Por: Artiko
luaerror-handlingpcallxpcall

Capítulo 17: Manejo de Errores

El manejo de errores en Lua es fundamentalmente diferente al de lenguajes con sistema de excepciones tradicional. Lua utiliza un enfoque basado en protected calls y propagación explícita de errores que resulta eficiente y predecible.

1. error() y assert()

error(): Lanzar errores explícitos

La función error() termina la ejecución de la función actual y propaga un error:

function dividir(a, b)
    if b == 0 then
        error("División por cero no permitida")
    end
    return a / b
end

-- Esto detendrá el programa
-- dividir(10, 0)  -- lua: script.lua:3: División por cero no permitida

Niveles de error para control del stack trace:

function validar_edad(edad)
    if type(edad) ~= "number" then
        -- Nivel 1 (default): reporta el error en esta función
        error("Edad debe ser un número", 1)
    end

    if edad < 0 then
        -- Nivel 2: reporta el error en la función que llamó a validar_edad
        error("Edad no puede ser negativa", 2)
    end

    return edad
end

function procesar_usuario(edad)
    validar_edad(edad)  -- El error nivel 2 apuntará aquí
end

assert(): Validación concisa

assert() lanza un error si la condición es falsa:

function leer_archivo(nombre)
    local archivo = assert(io.open(nombre, "r"), "No se pudo abrir: " .. nombre)
    local contenido = archivo:read("*a")
    archivo:close()
    return contenido
end

-- Forma más verbosa equivalente:
function leer_archivo_verboso(nombre)
    local archivo = io.open(nombre, "r")
    if not archivo then
        error("No se pudo abrir: " .. nombre)
    end
    local contenido = archivo:read("*a")
    archivo:close()
    return contenido
end

Patrón común: assert con funciones que retornan (valor, error)

function parse_json(texto)
    local resultado, err = json.decode(texto)
    assert(resultado, "JSON inválido: " .. (err or "desconocido"))
    return resultado
end

2. pcall: Protected Calls

pcall (protected call) captura errores sin detener el programa:

function operacion_riesgosa()
    error("¡Algo salió mal!")
end

local ok, resultado = pcall(operacion_riesgosa)

if ok then
    print("Éxito:", resultado)
else
    print("Error capturado:", resultado)  -- resultado contiene el mensaje de error
end

-- Output: Error capturado: script.lua:2: ¡Algo salió mal!

Pasar argumentos a la función protegida

function dividir(a, b)
    if b == 0 then
        error("División por cero")
    end
    return a / b
end

local ok, resultado = pcall(dividir, 10, 2)
print(ok, resultado)  -- true    5

local ok, resultado = pcall(dividir, 10, 0)
print(ok, resultado)  -- false   script.lua:3: División por cero

Envolver funciones para retornar (ok, result)

function safe_call(func, ...)
    local ok, result_or_error = pcall(func, ...)
    if ok then
        return true, result_or_error
    else
        return false, result_or_error
    end
end

-- Uso más idiomático:
local ok, resultado = safe_call(dividir, 10, 0)
if not ok then
    print("Error:", resultado)
end

3. xpcall: Error Handler Custom

xpcall permite especificar un manejador de errores personalizado:

function error_handler(err)
    return "ERROR INTERCEPTADO: " .. tostring(err)
end

function operacion()
    local x = nil
    return x.campo  -- Esto causará un error
end

local ok, resultado = xpcall(operacion, error_handler)
print(ok)         -- false
print(resultado)  -- ERROR INTERCEPTADO: attempt to index a nil value

Agregar contexto al error

function enriquecer_error(err)
    return {
        mensaje = tostring(err),
        timestamp = os.time(),
        contexto = "Módulo de procesamiento"
    }
end

local ok, resultado = xpcall(operacion_critica, enriquecer_error)

if not ok then
    print("Error en:", resultado.contexto)
    print("Mensaje:", resultado.mensaje)
    print("Hora:", os.date("%c", resultado.timestamp))
end

4. debug.traceback

Obtener stack trace completo en errores:

function error_con_traceback(err)
    return debug.traceback("Error: " .. tostring(err), 2)
end

function nivel3()
    error("Fallo en nivel 3")
end

function nivel2()
    nivel3()
end

function nivel1()
    nivel2()
end

local ok, resultado = xpcall(nivel1, error_con_traceback)

if not ok then
    print(resultado)
end

--[[ Output:
Error: script.lua:6: Fallo en nivel 3
stack traceback:
    script.lua:2: in function 'error_con_traceback'
    [C]: in function 'error'
    script.lua:6: in function 'nivel3'
    script.lua:10: in function 'nivel2'
    script.lua:14: in function 'nivel1'
    script.lua:17: in main chunk
]]

Traceback personalizado

function traceback_simplificado(err)
    local trace = debug.traceback(err, 2)
    -- Eliminar líneas internas de Lua
    local lineas = {}
    for linea in trace:gmatch("[^\n]+") do
        if not linea:match("%[C%]") then  -- Filtrar llamadas C
            table.insert(lineas, linea)
        end
    end
    return table.concat(lineas, "\n")
end

5. Propagación de Errores

Patrón (ok, err): Estilo Go

function leer_configuracion(archivo)
    local file, err = io.open(archivo, "r")
    if not file then
        return nil, "No se pudo abrir archivo: " .. err
    end

    local contenido = file:read("*a")
    file:close()

    local config, parse_err = parse_json(contenido)
    if not config then
        return nil, "JSON inválido: " .. parse_err
    end

    return config, nil
end

-- Uso:
local config, err = leer_configuracion("config.json")
if err then
    print("Error:", err)
    return
end

print("Configuración cargada:", config.version)

Propagación con pcall

function procesar_datos(datos)
    -- Validar
    local ok, err = pcall(validar_esquema, datos)
    if not ok then
        return nil, "Validación falló: " .. err
    end

    -- Transformar
    local transformados, err = transformar(datos)
    if not transformados then
        return nil, err
    end

    -- Guardar
    local guardado, err = guardar_db(transformados)
    if not guardado then
        return nil, err
    end

    return transformados, nil
end

Error wrapping (envolver errores)

function wrap_error(operacion, err)
    return string.format("[%s] %s", operacion, tostring(err))
end

function cargar_plugin(nombre)
    local ok, plugin = pcall(require, nombre)
    if not ok then
        return nil, wrap_error("cargar_plugin", plugin)
    end

    local ok, err = pcall(plugin.inicializar)
    if not ok then
        return nil, wrap_error("inicializar_plugin", err)
    end

    return plugin, nil
end

6. Crear Jerarquía de Errores

Sistema de errores tipados

local ErrorTypes = {
    VALIDACION = "ValidationError",
    RED = "NetworkError",
    PERMISO = "PermissionError",
    NO_ENCONTRADO = "NotFoundError"
}

function crear_error(tipo, mensaje, detalles)
    return {
        tipo = tipo,
        mensaje = mensaje,
        detalles = detalles or {},
        timestamp = os.time()
    }
end

function es_error_de_tipo(err, tipo)
    return type(err) == "table" and err.tipo == tipo
end

-- Uso:
function buscar_usuario(id)
    if id < 0 then
        error(crear_error(
            ErrorTypes.VALIDACION,
            "ID inválido",
            {campo = "id", valor = id}
        ))
    end

    local usuario = db.usuarios[id]
    if not usuario then
        error(crear_error(
            ErrorTypes.NO_ENCONTRADO,
            "Usuario no existe",
            {id = id}
        ))
    end

    return usuario
end

-- Manejo:
local ok, resultado = pcall(buscar_usuario, -5)
if not ok then
    if es_error_de_tipo(resultado, ErrorTypes.VALIDACION) then
        print("Error de validación en campo:", resultado.detalles.campo)
    elseif es_error_de_tipo(resultado, ErrorTypes.NO_ENCONTRADO) then
        print("Recurso no encontrado con ID:", resultado.detalles.id)
    else
        print("Error desconocido:", resultado.mensaje)
    end
end

Factory de errores

local ErrorFactory = {}

function ErrorFactory.validacion(campo, mensaje)
    return crear_error(ErrorTypes.VALIDACION, mensaje, {campo = campo})
end

function ErrorFactory.no_encontrado(recurso, id)
    return crear_error(
        ErrorTypes.NO_ENCONTRADO,
        recurso .. " no encontrado",
        {recurso = recurso, id = id}
    )
end

function ErrorFactory.red(operacion, codigo)
    return crear_error(
        ErrorTypes.RED,
        "Fallo en operación de red: " .. operacion,
        {operacion = operacion, codigo = codigo}
    )
end

-- Uso:
function validar_email(email)
    if not email:match("@") then
        error(ErrorFactory.validacion("email", "Formato de email inválido"))
    end
end

7. Patrón (ok, result) vs Exceptions

Comparación de enfoques

Enfoque con pcall (estilo exception):

function procesar_pedido(pedido)
    local ok, err = pcall(function()
        validar_pedido(pedido)
        procesar_pago(pedido)
        actualizar_inventario(pedido)
        enviar_confirmacion(pedido)
    end)

    if not ok then
        log_error(err)
        return false
    end

    return true
end

Enfoque con (ok, err) explícito (estilo Go):

function procesar_pedido(pedido)
    local ok, err = validar_pedido(pedido)
    if err then
        log_error(err)
        return nil, err
    end

    local pago, err = procesar_pago(pedido)
    if err then
        log_error(err)
        return nil, err
    end

    local inventario, err = actualizar_inventario(pedido)
    if err then
        log_error(err)
        revertir_pago(pago)  -- Rollback
        return nil, err
    end

    local ok, err = enviar_confirmacion(pedido)
    if err then
        log_error(err)
        -- Continuar de todos modos
    end

    return pedido, nil
end

Ventajas y desventajas

Aspectopcall/xpcall(ok, err) explícito
VerbosidadMenos códigoMás verboso
Control flujoAutomático (stack unwinding)Manual explícito
PerformanceOverhead mínimoMás eficiente
RollbackDifícilFácil de implementar
DebuggingStack trace completoContexto explícito

8. DEEP DIVE: Stack Unwinding

Cómo funciona la propagación de errores

Cuando se llama a error(), Lua realiza “stack unwinding”:

function mostrar_stack_unwinding()
    local function cleanup_nivel3()
        print("Cleanup nivel 3 ejecutado")
    end

    local function nivel3()
        -- Este cleanup NO se ejecutará si hay error
        -- Lua no tiene finally o defer
        cleanup_nivel3()
        error("Fallo en nivel 3")
    end

    local function nivel2()
        print("Entrando a nivel 2")
        nivel3()
        print("Esta línea NO se ejecuta")
    end

    local function nivel1()
        print("Entrando a nivel 1")
        nivel2()
        print("Esta línea NO se ejecuta")
    end

    local ok, err = pcall(nivel1)
    print("Error capturado:", err)
end

mostrar_stack_unwinding()
--[[
Entrando a nivel 1
Entrando a nivel 2
Cleanup nivel 3 ejecutado
Error capturado: script.lua:9: Fallo en nivel 3
]]

Simular RAII/Finally con xpcall

function with_resource(crear, destruir, callback)
    local recurso = crear()

    local function cleanup_handler(err)
        destruir(recurso)
        return err
    end

    local ok, resultado = xpcall(
        function() return callback(recurso) end,
        cleanup_handler
    )

    if ok then
        destruir(recurso)
        return resultado
    else
        error(resultado)
    end
end

-- Uso:
with_resource(
    function()
        print("Abriendo archivo")
        return io.open("datos.txt", "r")
    end,
    function(archivo)
        print("Cerrando archivo")
        archivo:close()
    end,
    function(archivo)
        local contenido = archivo:read("*a")
        error("Simulando error después de leer")  -- El archivo SE cerrará
        return contenido
    end
)

Metatables y propagación de errores

local FileWrapper = {}
FileWrapper.__index = FileWrapper

function FileWrapper.new(nombre)
    local self = setmetatable({}, FileWrapper)
    local file, err = io.open(nombre, "r")
    if not file then
        error(err)
    end
    self.file = file
    return self
end

function FileWrapper:read()
    return self.file:read("*a")
end

function FileWrapper:__gc()
    -- Garantizado al recolectarse
    print("Cerrando archivo en __gc")
    if self.file then
        self.file:close()
    end
end

-- El archivo se cerrará incluso con error
local ok, err = pcall(function()
    local fw = FileWrapper.new("datos.txt")
    local contenido = fw:read()
    error("Error simulado")
end)

collectgarbage("collect")  -- Forzar GC para ver el __gc

9. SOAPBOX: Errors vs Exceptions

La filosofía de Lua sobre errores

Lua deliberadamente eligió no tener un sistema de excepciones tradicional (try/catch). En su lugar, ofrece herramientas simples y composables.

Ventajas del enfoque Lua:

  1. Explicititud: El flujo de control es siempre visible
  2. Performance: Sin overhead de stack unwinding automático
  3. Simplicidad: Menos construcciones del lenguaje
  4. Composabilidad: pcall/xpcall son funciones normales

Desventajas:

  1. Sin garantía de cleanup: No hay finally/defer nativo
  2. Verboso: Código de manejo de errores puede repetirse
  3. Curva de aprendizaje: Programadores esperan try/catch

Cuándo usar cada patrón

Usa pcall/xpcall cuando:

Usa (ok, err) cuando:

-- pcall: Error inesperado
local ok, plugin = pcall(require, "plugin_terceros")
if not ok then
    log_fatal("No se pudo cargar plugin:", plugin)
    os.exit(1)
end

-- (ok, err): Flujo esperado
local archivo, err = io.open("config.json", "r")
if not archivo then
    print("Config no encontrado, usando defaults")
    usar_configuracion_default()
else
    cargar_configuracion(archivo)
    archivo:close()
end

10. Caso Práctico: Validador de Datos con Errores Detallados

Sistema completo de validación

-- 1. Tipos de error
local ValidationError = {tipo = "ValidationError"}
ValidationError.__index = ValidationError

function ValidationError.new(campo, mensaje, valor)
    local self = setmetatable({}, ValidationError)
    self.campo = campo
    self.mensaje = mensaje
    self.valor = valor
    self.timestamp = os.time()
    return self
end

function ValidationError:__tostring()
    return string.format(
        "[ValidationError] Campo '%s': %s (valor: %s)",
        self.campo,
        self.mensaje,
        tostring(self.valor)
    )
end

-- 2. Validadores básicos
local Validators = {}

function Validators.required(valor, campo)
    if valor == nil or valor == "" then
        error(ValidationError.new(
            campo,
            "Campo requerido",
            valor
        ))
    end
    return valor
end

function Validators.min_length(minimo)
    return function(valor, campo)
        if type(valor) == "string" and #valor < minimo then
            error(ValidationError.new(
                campo,
                string.format("Mínimo %d caracteres", minimo),
                valor
            ))
        end
        return valor
    end
end

function Validators.email(valor, campo)
    if not valor:match("^[%w._%+-]+@[%w.-]+%.[%a]+$") then
        error(ValidationError.new(
            campo,
            "Formato de email inválido",
            valor
        ))
    end
    return valor
end

function Validators.range(min, max)
    return function(valor, campo)
        if type(valor) ~= "number" or valor < min or valor > max then
            error(ValidationError.new(
                campo,
                string.format("Debe estar entre %d y %d", min, max),
                valor
            ))
        end
        return valor
    end
end

-- 3. Motor de validación
local Schema = {}
Schema.__index = Schema

function Schema.new(definicion)
    local self = setmetatable({}, Schema)
    self.campos = definicion
    return self
end

function Schema:validate(datos)
    local errores = {}
    local resultado = {}

    for campo, validadores in pairs(self.campos) do
        local valor = datos[campo]
        local ok, err = pcall(function()
            for _, validador in ipairs(validadores) do
                valor = validador(valor, campo)
            end
        end)

        if ok then
            resultado[campo] = valor
        else
            if getmetatable(err) == ValidationError then
                table.insert(errores, err)
            else
                -- Error inesperado
                table.insert(errores, ValidationError.new(
                    campo,
                    "Error de validación: " .. tostring(err),
                    valor
                ))
            end
        end
    end

    if #errores > 0 then
        return nil, errores
    end

    return resultado, nil
end

-- 4. Uso del sistema
local schema_usuario = Schema.new({
    nombre = {
        Validators.required,
        Validators.min_length(3)
    },
    email = {
        Validators.required,
        Validators.email
    },
    edad = {
        Validators.required,
        Validators.range(18, 100)
    }
})

-- Validar datos
local datos_usuario = {
    nombre = "Jo",  -- Muy corto
    email = "invalido",  -- No es email
    edad = 150  -- Fuera de rango
}

local usuario, errores = schema_usuario:validate(datos_usuario)

if errores then
    print("Errores de validación encontrados:")
    for _, err in ipairs(errores) do
        print("  -", tostring(err))
    end
else
    print("Usuario validado:", usuario.nombre, usuario.email)
end

--[[
Errores de validación encontrados:
  - [ValidationError] Campo 'nombre': Mínimo 3 caracteres (valor: Jo)
  - [ValidationError] Campo 'email': Formato de email inválido (valor: invalido)
  - [ValidationError] Campo 'edad': Debe estar entre 18 y 100 (valor: 150)
]]

Validación con transformación

-- Validadores que transforman
function Validators.trim(valor, campo)
    if type(valor) == "string" then
        return valor:match("^%s*(.-)%s*$")
    end
    return valor
end

function Validators.uppercase(valor, campo)
    if type(valor) == "string" then
        return valor:upper()
    end
    return valor
end

function Validators.to_number(valor, campo)
    local num = tonumber(valor)
    if not num then
        error(ValidationError.new(campo, "No es un número válido", valor))
    end
    return num
end

-- Schema con transformación
local schema_producto = Schema.new({
    codigo = {
        Validators.required,
        Validators.trim,
        Validators.uppercase
    },
    precio = {
        Validators.required,
        Validators.to_number,
        Validators.range(0, 999999)
    }
})

local producto, err = schema_producto:validate({
    codigo = "  abc123  ",
    precio = "29.99"
})

if producto then
    print("Código:", producto.codigo)  -- ABC123
    print("Precio:", producto.precio)   -- 29.99 (number)
end

11. Ejercicios

Ejercicio 1: Sistema de Retry con Backoff

Implementa una función que reintente operaciones fallidas con backoff exponencial:

--[[
retry(funcion, intentos_maximos, backoff_inicial) -> resultado, error

Debe:
1. Reintentar la función hasta intentos_maximos veces
2. Usar backoff exponencial: esperar 1s, 2s, 4s, 8s...
3. Retornar (resultado, nil) si tiene éxito
4. Retornar (nil, ultimo_error) si falla todos los intentos
5. Loguear cada intento y error

Ejemplo de uso:
local resultado, err = retry(
    function() return hacer_peticion_http() end,
    5,
    1  -- Empezar con 1 segundo
)
]]

function retry(func, max_intentos, backoff_inicial)
    -- Tu código aquí
end

-- Prueba:
local intentos = 0
local resultado, err = retry(
    function()
        intentos = intentos + 1
        if intentos < 3 then
            error("Intento " .. intentos .. " falló")
        end
        return "¡Éxito en intento " .. intentos .. "!"
    end,
    5,
    0.1
)

print(resultado or err)

Ejercicio 2: Circuit Breaker

Implementa el patrón Circuit Breaker para prevenir llamadas a servicios fallidos:

--[[
CircuitBreaker:
- Estados: CLOSED (normal), OPEN (bloqueado), HALF_OPEN (probando)
- Si falla threshold veces consecutivas, abre el circuito
- Después de timeout segundos, pasa a HALF_OPEN
- En HALF_OPEN, permite 1 intento: éxito -> CLOSED, fallo -> OPEN

Interfaz:
cb = CircuitBreaker.new(threshold, timeout)
resultado, err = cb:call(funcion)
estado = cb:get_estado()
]]

local CircuitBreaker = {}
CircuitBreaker.__index = CircuitBreaker

function CircuitBreaker.new(threshold, timeout)
    -- Tu código aquí
end

function CircuitBreaker:call(func)
    -- Tu código aquí
end

function CircuitBreaker:get_estado()
    -- Tu código aquí
end

-- Prueba:
local cb = CircuitBreaker.new(3, 2)  -- 3 fallos, 2 segundos timeout

for i = 1, 10 do
    local ok, err = cb:call(function()
        if math.random() > 0.7 then
            return "OK"
        else
            error("Servicio falló")
        end
    end)

    print(string.format(
        "Intento %d: %s (Estado: %s)",
        i,
        ok or err,
        cb:get_estado()
    ))

    os.execute("sleep 0.5")
end

Ejercicio 3: Error Aggregator

Crea un sistema que acumule múltiples errores antes de fallar:

--[[
ErrorAggregator:
- Acumula errores en vez de fallar inmediatamente
- Permite continuar validaciones incluso con errores
- Retorna todos los errores al final

Interfaz:
agg = ErrorAggregator.new()
agg:try(funcion, contexto)  -- Ejecuta y captura errores
tiene_errores = agg:has_errors()
errores = agg:get_errors()
resultado = agg:throw_if_errors()  -- Lanza si hay errores
]]

local ErrorAggregator = {}
ErrorAggregator.__index = ErrorAggregator

function ErrorAggregator.new()
    -- Tu código aquí
end

function ErrorAggregator:try(func, contexto)
    -- Tu código aquí
end

function ErrorAggregator:has_errors()
    -- Tu código aquí
end

function ErrorAggregator:get_errors()
    -- Tu código aquí
end

function ErrorAggregator:throw_if_errors()
    -- Tu código aquí
end

-- Prueba:
local agg = ErrorAggregator.new()

agg:try(function()
    validar_nombre("A")  -- Falla
end, "validacion_nombre")

agg:try(function()
    validar_email("invalido")  -- Falla
end, "validacion_email")

agg:try(function()
    validar_edad(200)  -- Falla
end, "validacion_edad")

if agg:has_errors() then
    print("Errores encontrados:")
    for _, err in ipairs(agg:get_errors()) do
        print(string.format("  [%s] %s", err.contexto, err.mensaje))
    end
end

Recursos Adicionales


Próximo capítulo: Módulos y Packages - Sistema de módulos, require, package.path, y creación de bibliotecas reutilizables.