Ir al contenido principal

Manejo avanzado de excepciones

El manejo robusto de excepciones es una de las características más importantes para crear aplicaciones confiables y mantenibles. Mientras que los conceptos básicos de try-catch proporcionan una base sólida, C# ofrece herramientas avanzadas que permiten gestionar errores de manera más sofisticada y eficiente. El dominio de estas técnicas avanzadas diferencia a un programador novato de uno experimentado, ya que permite crear aplicaciones que no solo funcionan correctamente en condiciones normales, sino que también responden elegantemente ante situaciones inesperadas.

En este artículo exploraremos las técnicas avanzadas para el manejo de excepciones en C#, incluyendo jerarquías de excepciones personalizadas, filtros de excepción, técnicas de logging y recuperación, y patrones de diseño específicos para el manejo de errores. Estos conocimientos te permitirán crear aplicaciones más robustas y profesionales.

Jerarquía de excepciones en .NET

Sistema de herencia de excepciones

En .NET, todas las excepciones heredan de la clase base System.Exception. Esta jerarquía permite capturar excepciones de manera específica o general según las necesidades del programa.

Tipo de excepción Descripción Cuándo usar
Exception Clase base de todas las excepciones Para capturar cualquier excepción
SystemException Excepciones generadas por el runtime Errores del sistema
ApplicationException Excepciones específicas de aplicación Errores de lógica de negocio
ArgumentException Argumentos incorrectos en métodos Validación de parámetros
InvalidOperationException Operación inválida en el estado actual Estados inconsistentes
NotImplementedException Funcionalidad no implementada Métodos pendientes de implementar

Captura específica vs genérica

La captura específica de excepciones permite manejar diferentes tipos de errores de manera apropiada:

using System;
using System.IO;

class ProgramaEjemplo
{
    static void Main()
    {
        try
        {
            ProcesarArchivo("datos.txt");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"Archivo no encontrado: {ex.FileName}");
            // Lógica específica para archivo no encontrado
        }
        catch (UnauthorizedAccessException ex)
        {
            Console.WriteLine($"Sin permisos para acceder al archivo: {ex.Message}");
            // Lógica específica para problemas de permisos
        }
        catch (IOException ex)
        {
            Console.WriteLine($"Error de E/S: {ex.Message}");
            // Lógica específica para errores de entrada/salida
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error inesperado: {ex.Message}");
            // Lógica general para cualquier otro error
        }
    }

    static void ProcesarArchivo(string rutaArchivo)
    {
        // Simular procesamiento de archivo que puede fallar
        string contenido = File.ReadAllText(rutaArchivo);
        Console.WriteLine($"Archivo procesado: {contenido.Length} caracteres");
    }
}

Creación de excepciones personalizadas

Diseño de excepciones específicas del dominio

Las excepciones personalizadas permiten crear un sistema de manejo de errores específico para tu aplicación:

using System;

// Excepción base para errores de cuenta bancaria
public class CuentaBancariaException : Exception
{
    public string NumeroCuenta { get; }

    public CuentaBancariaException(string numeroCuenta, string mensaje)
        : base(mensaje)
    {
        NumeroCuenta = numeroCuenta;
    }

    public CuentaBancariaException(string numeroCuenta, string mensaje, Exception innerException)
        : base(mensaje, innerException)
    {
        NumeroCuenta = numeroCuenta;
    }
}

// Excepción específica para saldo insuficiente
public class SaldoInsuficienteException : CuentaBancariaException
{
    public decimal SaldoActual { get; }
    public decimal MontoSolicitado { get; }

    public SaldoInsuficienteException(string numeroCuenta, decimal saldoActual, decimal montoSolicitado)
        : base(numeroCuenta, $"Saldo insuficiente. Saldo actual: {saldoActual:C}, Monto solicitado: {montoSolicitado:C}")
    {
        SaldoActual = saldoActual;
        MontoSolicitado = montoSolicitado;
    }
}

// Excepción para cuenta bloqueada
public class CuentaBloqueadaException : CuentaBancariaException
{
    public DateTime FechaBloqueo { get; }
    public string MotivoBloqueo { get; }

    public CuentaBloqueadaException(string numeroCuenta, DateTime fechaBloqueo, string motivo)
        : base(numeroCuenta, $"La cuenta está bloqueada desde {fechaBloqueo:dd/MM/yyyy}. Motivo: {motivo}")
    {
        FechaBloqueo = fechaBloqueo;
        MotivoBloqueo = motivo;
    }
}

// Clase que utiliza las excepciones personalizadas
public class CuentaBancaria
{
    public string Numero { get; }
    public decimal Saldo { get; private set; }
    public bool EstaBloqueada { get; private set; }

    public CuentaBancaria(string numero, decimal saldoInicial)
    {
        Numero = numero;
        Saldo = saldoInicial;
        EstaBloqueada = false;
    }

    public void Retirar(decimal monto)
    {
        if (EstaBloqueada)
        {
            throw new CuentaBloqueadaException(Numero, DateTime.Now.AddDays(-5), "Actividad sospechosa");
        }

        if (monto > Saldo)
        {
            throw new SaldoInsuficienteException(Numero, Saldo, monto);
        }

        if (monto <= 0)
        {
            throw new ArgumentException("El monto debe ser positivo", nameof(monto));
        }

        Saldo -= monto;
        Console.WriteLine($"Retiro exitoso. Nuevo saldo: {Saldo:C}");
    }

    public void BloquearCuenta()
    {
        EstaBloqueada = true;
    }
}

Uso de excepciones personalizadas

class ProgramaBanco
{
    static void Main()
    {
        var cuenta = new CuentaBancaria("12345", 1000m);
        
        try
        {
            cuenta.Retirar(1500m); // Esto provocará una excepción de saldo insuficiente
        }
        catch (SaldoInsuficienteException ex)
        {
            Console.WriteLine($"Error en cuenta {ex.NumeroCuenta}:");
            Console.WriteLine($"  {ex.Message}");
            Console.WriteLine($"  Déficit: {ex.MontoSolicitado - ex.SaldoActual:C}");
        }
        catch (CuentaBloqueadaException ex)
        {
            Console.WriteLine($"Cuenta {ex.NumeroCuenta} bloqueada:");
            Console.WriteLine($"  Fecha: {ex.FechaBloqueo:dd/MM/yyyy}");
            Console.WriteLine($"  Motivo: {ex.MotivoBloqueo}");
        }
        catch (CuentaBancariaException ex)
        {
            Console.WriteLine($"Error general en cuenta {ex.NumeroCuenta}: {ex.Message}");
        }

        // Ejemplo con cuenta bloqueada
        cuenta.BloquearCuenta();
        try
        {
            cuenta.Retirar(100m);
        }
        catch (CuentaBloqueadaException ex)
        {
            Console.WriteLine($"Operación denegada: {ex.Message}");
        }
    }
}

Filtros de excepción y cláusulas when

Uso de filtros condicionales

Los filtros de excepción permiten capturar excepciones solo cuando se cumple una condición específica:

using System;
using System.Net.Http;

class EjemploFiltrosExcepcion
{
    static void Main()
    {
        for (int intento = 1; intento <= 3; intento++)
        {
            try
            {
                RealizarOperacionRed(intento);
                break; // Operación exitosa, salir del bucle
            }
            catch (HttpRequestException ex) when (intento < 3)
            {
                Console.WriteLine($"Intento {intento} falló: {ex.Message}");
                Console.WriteLine("Reintentando...");
                System.Threading.Thread.Sleep(1000); // Esperar 1 segundo
            }
            catch (HttpRequestException ex) when (intento == 3)
            {
                Console.WriteLine($"Operación falló después de 3 intentos: {ex.Message}");
                throw; // Relanzar la excepción después del último intento
            }
        }
    }

    static void RealizarOperacionRed(int intento)
    {
        // Simular una operación de red que falla las primeras veces
        Random random = new Random();
        if (intento < 3 && random.Next(1, 10) > 3)
        {
            throw new HttpRequestException($"Error de conexión en intento {intento}");
        }
        
        Console.WriteLine($"Operación de red exitosa en intento {intento}");
    }
}

Filtros avanzados con condiciones complejas

using System;
using System.IO;

class FiltrosAvanzados
{
    static void Main()
    {
        string[] archivos = { "documento.txt", "imagen.jpg", "datos.xml" };

        foreach (string archivo in archivos)
        {
            try
            {
                ProcesarArchivo(archivo);
            }
            catch (FileNotFoundException ex) when (ex.FileName.EndsWith(".txt"))
            {
                Console.WriteLine($"Creando archivo de texto faltante: {ex.FileName}");
                File.WriteAllText(ex.FileName, "Archivo creado automáticamente");
                ProcesarArchivo(archivo); // Reintentar
            }
            catch (FileNotFoundException ex) when (ex.FileName.EndsWith(".jpg"))
            {
                Console.WriteLine($"Archivo de imagen no crítico faltante: {ex.FileName}");
                // Continuar sin el archivo de imagen
            }
            catch (FileNotFoundException ex)
            {
                Console.WriteLine($"Archivo crítico faltante: {ex.FileName}");
                throw; // Relanzar para archivos críticos
            }
        }
    }

    static void ProcesarArchivo(string nombreArchivo)
    {
        if (!File.Exists(nombreArchivo))
        {
            throw new FileNotFoundException($"No se encontró el archivo", nombreArchivo);
        }
        
        Console.WriteLine($"Procesando archivo: {nombreArchivo}");
    }
}

Manejo de excepciones agregadas

Excepciones múltiples con AggregateException

Cuando se trabaja con tareas paralelas o múltiples operaciones, es común encontrar múltiples excepciones:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class EjemploExcepcionesAgregadas
{
    static async Task Main()
    {
        List<Task> tareas = new List<Task>();

        // Crear múltiples tareas que pueden fallar
        for (int i = 1; i <= 5; i++)
        {
            int indice = i;
            tareas.Add(Task.Run(() => OperacionQuePuedeFallar(indice)));
        }

        try
        {
            await Task.WhenAll(tareas);
            Console.WriteLine("Todas las tareas completadas exitosamente");
        }
        catch (Exception ex)
        {
            // En operaciones asíncronas, las excepciones se envuelven en AggregateException
            if (ex is AggregateException aggregateEx)
            {
                Console.WriteLine($"Se encontraron {aggregateEx.InnerExceptions.Count} errores:");
                
                foreach (var innerEx in aggregateEx.InnerExceptions)
                {
                    Console.WriteLine($"  - {innerEx.Message}");
                }
            }
            else
            {
                Console.WriteLine($"Error único: {ex.Message}");
            }
        }
    }

    static void OperacionQuePuedeFallar(int indice)
    {
        Random random = new Random(indice); // Semilla diferente para cada tarea
        System.Threading.Thread.Sleep(100 * indice); // Simular trabajo

        if (random.Next(1, 10) > 6) // 40% de probabilidad de fallo
        {
            throw new InvalidOperationException($"Error en operación {indice}");
        }

        Console.WriteLine($"Operación {indice} completada exitosamente");
    }
}

Manejo selectivo de excepciones agregadas

using System;
using System.Collections.Generic;
using System.Linq;

class ManejadorExcepcionesSelectivo
{
    static void Main()
    {
        try
        {
            EjecutarOperacionesParalelas();
        }
        catch (AggregateException ex)
        {
            // Separar excepciones críticas de no críticas
            var excepcionesCriticas = ex.InnerExceptions
                .OfType<ArgumentException>()
                .ToList();

            var excepcionesNoCriticas = ex.InnerExceptions
                .Where(e => !(e is ArgumentException))
                .ToList();

            // Manejar excepciones no críticas
            foreach (var excepcionNoCritica in excepcionesNoCriticas)
            {
                Console.WriteLine($"Advertencia: {excepcionNoCritica.Message}");
            }

            // Si hay excepciones críticas, relanzar solo esas
            if (excepcionesCriticas.Any())
            {
                throw new AggregateException("Errores críticos detectados", excepcionesCriticas);
            }
        }
    }

    static void EjecutarOperacionesParalelas()
    {
        List<Exception> excepciones = new List<Exception>();

        try { OperacionA(); }
        catch (Exception ex) { excepciones.Add(ex); }

        try { OperacionB(); }
        catch (Exception ex) { excepciones.Add(ex); }

        try { OperacionC(); }
        catch (Exception ex) { excepciones.Add(ex); }

        if (excepciones.Any())
        {
            throw new AggregateException("Múltiples operaciones fallaron", excepciones);
        }
    }

    static void OperacionA() => throw new InvalidOperationException("Error en operación A");
    static void OperacionB() => throw new ArgumentException("Error crítico en operación B");
    static void OperacionC() => Console.WriteLine("Operación C exitosa");
}

Logging y monitoreo de excepciones

Sistema de logging integrado

using System;
using System.IO;
using System.Text.Json;

public class LoggerExcepciones
{
    private readonly string rutaLog;

    public LoggerExcepciones(string rutaLog = "excepciones.log")
    {
        this.rutaLog = rutaLog;
    }

    public void LogearExcepcion(Exception ex, string contexto = null)
    {
        var entrada = new
        {
            Timestamp = DateTime.Now,
            Tipo = ex.GetType().Name,
            Mensaje = ex.Message,
            StackTrace = ex.StackTrace,
            Contexto = contexto,
            InnerException = ex.InnerException?.Message
        };

        string json = JsonSerializer.Serialize(entrada, new JsonSerializerOptions 
        { 
            WriteIndented = true 
        });

        File.AppendAllText(rutaLog, json + Environment.NewLine + new string('-', 80) + Environment.NewLine);
    }

    public void LogearExcepcionConDetalles(Exception ex, object datosAdicionales = null)
    {
        var entrada = new
        {
            Timestamp = DateTime.Now,
            Excepcion = new
            {
                Tipo = ex.GetType().Name,
                Mensaje = ex.Message,
                StackTrace = ex.StackTrace?.Split('\n').Take(5).ToArray(), // Primeras 5 líneas
                InnerException = ex.InnerException?.Message
            },
            DatosAdicionales = datosAdicionales,
            Usuario = Environment.UserName,
            Maquina = Environment.MachineName
        };

        string json = JsonSerializer.Serialize(entrada, new JsonSerializerOptions 
        { 
            WriteIndented = true 
        });

        File.AppendAllText(rutaLog, json + Environment.NewLine + new string('=', 80) + Environment.NewLine);
    }
}

// Ejemplo de uso del logger
class EjemploLogging
{
    static LoggerExcepciones logger = new LoggerExcepciones();

    static void Main()
    {
        try
        {
            OperacionCompleja("usuario123", 42);
        }
        catch (ArgumentException ex)
        {
            logger.LogearExcepcion(ex, "Validación de argumentos en OperacionCompleja");
            Console.WriteLine("Error de validación registrado en el log");
        }
        catch (Exception ex)
        {
            var contexto = new 
            { 
                Metodo = "OperacionCompleja",
                Parametros = new { usuario = "usuario123", valor = 42 },
                FechaEjecucion = DateTime.Now
            };

            logger.LogearExcepcionConDetalles(ex, contexto);
            Console.WriteLine("Error inesperado registrado con detalles completos");
            throw; // Relanzar después de logear
        }
    }

    static void OperacionCompleja(string usuario, int valor)
    {
        if (string.IsNullOrEmpty(usuario))
            throw new ArgumentException("El usuario no puede estar vacío", nameof(usuario));

        if (valor < 0)
            throw new ArgumentException("El valor debe ser positivo", nameof(valor));

        // Simular una operación que puede fallar
        if (valor > 100)
            throw new InvalidOperationException($"Valor {valor} fuera del rango permitido");

        Console.WriteLine($"Operación exitosa para usuario {usuario} con valor {valor}");
    }
}

Patrones de recuperación y retry

Patrón de reintento con backoff exponencial

using System;
using System.Threading;
using System.Threading.Tasks;

public class GestorReintentos
{
    public async Task<T> EjecutarConReintentos<T>(
        Func<Task<T>> operacion,
        int maxIntentos = 3,
        TimeSpan retrasoInicial = default,
        double multiplicadorBackoff = 2.0)
    {
        if (retrasoInicial == default)
            retrasoInicial = TimeSpan.FromMilliseconds(100);

        var retrasoActual = retrasoInicial;
        Exception ultimaExcepcion = null;

        for (int intento = 1; intento <= maxIntentos; intento++)
        {
            try
            {
                return await operacion();
            }
            catch (Exception ex)
            {
                ultimaExcepcion = ex;
                
                Console.WriteLine($"Intento {intento}/{maxIntentos} falló: {ex.Message}");

                if (intento == maxIntentos)
                {
                    Console.WriteLine("Se agotaron todos los intentos");
                    break;
                }

                Console.WriteLine($"Esperando {retrasoActual.TotalMilliseconds}ms antes del siguiente intento");
                await Task.Delay(retrasoActual);
                
                // Incrementar el retraso para el siguiente intento (backoff exponencial)
                retrasoActual = TimeSpan.FromMilliseconds(retrasoActual.TotalMilliseconds * multiplicadorBackoff);
            }
        }

        throw new AggregateException($"Operación falló después de {maxIntentos} intentos", ultimaExcepcion);
    }

    public async Task EjecutarConReintentos(
        Func<Task> operacion,
        int maxIntentos = 3,
        TimeSpan retrasoInicial = default,
        double multiplicadorBackoff = 2.0)
    {
        await EjecutarConReintentos(async () =>
        {
            await operacion();
            return true; // Valor dummy para convertir Task en Task<T>
        }, maxIntentos, retrasoInicial, multiplicadorBackoff);
    }
}

// Ejemplo de uso del patrón de reintento
class EjemploPatronReintento
{
    static async Task Main()
    {
        var gestor = new GestorReintentos();

        try
        {
            // Operación que puede fallar
            var resultado = await gestor.EjecutarConReintentos(
                operacion: () => OperacionInestable(),
                maxIntentos: 5,
                retrasoInicial: TimeSpan.FromMilliseconds(200),
                multiplicadorBackoff: 1.5
            );

            Console.WriteLine($"Operación completada exitosamente: {resultado}");
        }
        catch (AggregateException ex)
        {
            Console.WriteLine($"La operación falló definitivamente: {ex.InnerException?.Message}");
        }
    }

    static async Task<string> OperacionInestable()
    {
        await Task.Delay(100); // Simular trabajo asíncrono

        Random random = new Random();
        if (random.Next(1, 10) > 7) // 30% de probabilidad de éxito
        {
            return "Datos procesados correctamente";
        }

        throw new InvalidOperationException("Servicio temporalmente no disponible");
    }
}

Circuit Breaker Pattern

Implementación del patrón Circuit Breaker

using System;
using System.Threading.Tasks;

public enum EstadoCircuitBreaker
{
    Cerrado,    // Funcionamiento normal
    Abierto,    // Bloqueando llamadas
    MedioCerrado // Probando si el servicio se recuperó
}

public class CircuitBreaker
{
    private readonly int umbralFallos;
    private readonly TimeSpan tiempoEspera;
    private readonly object lockObject = new object();
    
    private int contadorFallos = 0;
    private DateTime ultimoFallo = DateTime.MinValue;
    private EstadoCircuitBreaker estado = EstadoCircuitBreaker.Cerrado;

    public CircuitBreaker(int umbralFallos = 5, TimeSpan? tiempoEspera = null)
    {
        this.umbralFallos = umbralFallos;
        this.tiempoEspera = tiempoEspera ?? TimeSpan.FromMinutes(1);
    }

    public async Task<T> Ejecutar<T>(Func<Task<T>> operacion)
    {
        lock (lockObject)
        {
            if (estado == EstadoCircuitBreaker.Abierto)
            {
                if (DateTime.Now - ultimoFallo < tiempoEspera)
                {
                    throw new InvalidOperationException("Circuit breaker abierto: servicio no disponible");
                }
                else
                {
                    estado = EstadoCircuitBreaker.MedioCerrado;
                    Console.WriteLine("Circuit breaker en modo medio-cerrado: probando servicio");
                }
            }
        }

        try
        {
            var resultado = await operacion();
            
            lock (lockObject)
            {
                if (estado == EstadoCircuitBreaker.MedioCerrado)
                {
                    estado = EstadoCircuitBreaker.Cerrado;
                    contadorFallos = 0;
                    Console.WriteLine("Circuit breaker cerrado: servicio recuperado");
                }
            }
            
            return resultado;
        }
        catch (Exception ex)
        {
            lock (lockObject)
            {
                contadorFallos++;
                ultimoFallo = DateTime.Now;

                if (contadorFallos >= umbralFallos)
                {
                    estado = EstadoCircuitBreaker.Abierto;
                    Console.WriteLine($"Circuit breaker abierto: demasiados fallos ({contadorFallos})");
                }
                else if (estado == EstadoCircuitBreaker.MedioCerrado)
                {
                    estado = EstadoCircuitBreaker.Abierto;
                    Console.WriteLine("Circuit breaker abierto: fallo en modo medio-cerrado");
                }
            }

            throw;
        }
    }
}

// Ejemplo de uso del Circuit Breaker
class EjemploCircuitBreaker
{
    static async Task Main()
    {
        var circuitBreaker = new CircuitBreaker(
            umbralFallos: 3, 
            tiempoEspera: TimeSpan.FromSeconds(5)
        );

        // Simular múltiples llamadas que pueden fallar
        for (int i = 1; i <= 10; i++)
        {
            try
            {
                var resultado = await circuitBreaker.Ejecutar(() => ServicioInestable(i));
                Console.WriteLine($"Llamada {i}: {resultado}");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Llamada {i} falló: {ex.Message}");
            }

            await Task.Delay(1000); // Esperar entre llamadas
        }
    }

    static async Task<string> ServicioInestable(int llamada)
    {
        await Task.Delay(100);

        // Simular servicio que falla las primeras llamadas pero se recupera
        if (llamada <= 4)
        {
            throw new InvalidOperationException($"Servicio falló en llamada {llamada}");
        }

        return $"Respuesta exitosa para llamada {llamada}";
    }
}

Resumen

El manejo avanzado de excepciones en C# proporciona herramientas sofisticadas para crear aplicaciones robustas y confiables. Las excepciones personalizadas permiten modelar errores específicos del dominio, mientras que los filtros de excepción ofrecen control granular sobre cuándo y cómo capturar errores. Los patrones como Circuit Breaker y sistemas de reintento ayudan a construir aplicaciones resilientes que pueden recuperarse automáticamente de fallos temporales.

El logging detallado de excepciones facilita la depuración y el monitoreo en producción, mientras que el manejo de excepciones agregadas permite gestionar errores en operaciones paralelas. Dominar estas técnicas avanzadas te permitirá crear aplicaciones que no solo funcionen correctamente en condiciones normales, sino que también respondan elegantemente ante situaciones inesperadas, proporcionando una mejor experiencia de usuario y facilitando el mantenimiento del código.