//JorgenHoc
← Todos los artículos
Async C#Por Jorge CalderónActualizado 24 min read

Manejo de Excepciones Async en C# — La Guía Completa

Maneja excepciones en código async de C#: try/catch con await, AggregateException de Task.WhenAll, problemas con async void, filtros de excepción y manejadores globales de tareas no observadas.

#csharp#async#dotnet

El código async en C# parece sencillo hasta que una excepción escapa en un método fire-and-forget, o pierdes 4 de 5 errores de Task.WhenAll porque solo capturaste el primero. Esta guía cubre todos los patrones de manejo de excepciones que necesitas para código async en producción.

Cada Afirmación Aquí Está Verificada, No Narrada

El enrutamiento de excepciones es completamente observable: qué bloque catch se ejecutó, qué tipo salió del await, en qué estado terminó la tarea, qué contiene un stack trace. Así que en lugar de pedirte que confíes en la prosa, convertí las afirmaciones de este artículo en aserciones: samples/async-exception-handling-csharp es un proyecto de consola donde cada línea de salida es una verificación que pasa. La única afirmación que no puede verificarse desde dentro de un proceso — "una excepción de async void lo mata" — se prueba relanzando el mismo ejecutable como proceso hijo e inspeccionando lo que devuelve.

Escribir el sample corrigió dos cosas que yo mismo tenía mal: la afirmación ampliamente repetida de que las excepciones de async void en ASP.NET Core pasan por Environment.FailFast (no lo hacen — ver más abajo), y mi suposición de que una tarea solo termina Canceled cuando su token fue realmente cancelado (tampoco).

DemoVerificado
Relanzamiento en awaitawait lanza el tipo de excepción original, no AggregateException; la tarea termina Faulted; task.Exception es el wrapper AggregateException
Throw antes del primer awaitllamar al método async no lanza; la excepción aparece en el await; un wrapper de validación no-async lanza en el punto de llamada
async void + SyncContextun try/catch alrededor de la llamada async void no captura nada; un SynchronizationContext personalizado recibe la excepción vía Post
async void sin SyncContextel proceso hijo muere por la ruta normal de excepción no manejada — AppDomain.UnhandledException se dispara con IsTerminating=true, algo que Environment.FailFast habría omitido
Task.WhenAllawait relanza solo la primera excepción; la tarea sana aún corrió hasta completarse; Exception.InnerExceptions de la tarea combinada contiene cada fallo
Agregados anidadoslas tareas hijas adjuntas anidan AggregateExceptions y Flatten() las colapsa; WhenAll-de-WhenAll se mantiene plano
Task.WhenAnyuna tarea ya fallida gana la carrera; await Task.WhenAny en sí nunca lanza; la excepción aparece al hacer await del ganador
Filtros de excepciónun filtro when que retorna false observa la excepción sin capturarla; la misma instancia sigue propagándose
Canceled vs Faultedun método async que lanza OperationCanceledException termina Canceled incluso con un token nunca cancelado; un delegado síncrono en Task.Run termina Faulted salvo que el token coincida
Stack tracesthrow; y ExceptionDispatchInfo conservan el frame original; throw ex; lo borra
Excepciones no observadasTaskScheduler.UnobservedTaskException se dispara durante la recolección de basura, no al momento del fallo
Salida de consola del sample: 36 verificaciones que pasan en 11 demos — await relanza el tipo de excepción original mientras task.Exception lo envuelve; un throw antes del primer await aparece solo en el await; las excepciones de async void aterrizan en el SynchronizationContext y, sin uno, matan un proceso hijo a través de AppDomain.UnhandledException con IsTerminating=true en lugar de Environment.FailFast; await Task.WhenAll relanza solo la primera excepción mientras la tarea combinada retiene ambas; las tareas hijas adjuntas anidan AggregateExceptions pero WhenAll anidado se mantiene plano; una tarea ya fallida gana Task.WhenAny; un filtro when observa sin capturar; los métodos async que lanzan OperationCanceledException terminan Canceled incluso con un token nunca cancelado mientras los delegados síncronos de Task.Run terminan Faulted salvo que el token coincida; throw; y ExceptionDispatchInfo conservan el frame original mientras throw ex lo borra; y TaskScheduler.UnobservedTaskException se dispara durante el GC. Todas las verificaciones pasaron.
La ejecución del sample: 36 aserciones en 11 demos, terminando con el resumen de una línea — las excepciones nunca desaparecen; se almacenan, se publican o se finalizan.

Cómo se Propagan las Excepciones en Métodos Async

Cuando haces await en una tarea fallida, la excepción almacenada dentro de ella se relanza en el punto del await. Esto significa que los bloques try/catch ordinarios funcionan exactamente como esperas:

public async Task<string> FetchDataAsync(string url)
{
    try
    {
        using var client = new HttpClient();
        // Si GetStringAsync lanza, la excepción queda capturada en la Task retornada.
        // Cuando hacemos await, la excepción se relanza aquí.
        return await client.GetStringAsync(url);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "La petición HTTP falló para {Url}", url);
        throw; // Relanzar para preservar el stack trace original
    }
}

El compilador transforma tu método async en una máquina de estados. Cuando ocurre una excepción dentro de esa máquina, es capturada y almacenada como el fallo de la Task. La excepción surge cuando el llamador hace await en la tarea.

Qué Sucede Sin await

Si nunca haces await en una tarea, la excepción se silencia — se convierte en una excepción no observada:

// MAL: La excepción de ProcessAsync() nunca se observa.
// Sin crash, sin log, nada. El bug es invisible.
_ = ProcessAsync();
 
// MEJOR: Como mínimo, adjunta una continuación para registrar fallos
Task.Run(ProcessAsync).ContinueWith(t =>
{
    if (t.IsFaulted)
        _logger.LogError(t.Exception, "La tarea en segundo plano falló");
}, TaskContinuationOptions.OnlyOnFaulted);

Excepciones Síncronas en Métodos Async

Una excepción lanzada antes del primer await en un método async sigue siendo capturada en la Task retornada — no escapa de forma síncrona:

public async Task DoWorkAsync(string input)
{
    // Esta ArgumentNullException queda capturada en la Task, NO se lanza síncronamente.
    // El llamador debe hacer await en la tarea para observarla.
    if (input is null) throw new ArgumentNullException(nameof(input));
 
    await Task.Delay(100);
}
 
// El llamador ve la excepción solo al hacer await:
try
{
    await DoWorkAsync(null); // La excepción surge aquí
}
catch (ArgumentNullException ex)
{
    Console.WriteLine(ex.Message);
}
💡

Para validación de argumentos que quieres ejecutar inmediatamente (antes de cualquier trabajo async), divide el método en un wrapper público síncrono que valida y una implementación async privada. Este es el patrón que usan muchos métodos de la BCL.

// Punto de entrada público — síncrono, lanza inmediatamente en entrada inválida
public Task DoWorkAsync(string input)
{
    if (input is null) throw new ArgumentNullException(nameof(input));
    return DoWorkCoreAsync(input);
}
 
private async Task DoWorkCoreAsync(string input)
{
    await Task.Delay(100);
    // ... trabajo real
}

El Problema de las Excepciones en async void

Los métodos async void son el patrón más peligroso en async C#. Las excepciones lanzadas dentro de ellos se elevan directamente en el SynchronizationContext que estaba activo cuando el método comenzó — no pueden ser capturadas por un try/catch circundante:

// PELIGROSO: La excepción no puede ser capturada por los llamadores
private async void OnButtonClick(object sender, EventArgs e)
{
    await Task.Delay(100);
    throw new InvalidOperationException("Esto crasheará el proceso");
}
 
// Este bloque catch NO HACE NADA para excepciones de async void:
try
{
    OnButtonClick(this, EventArgs.Empty); // Retorna inmediatamente (void)
}
catch (InvalidOperationException)
{
    // Nunca se alcanza. La excepción se elevó en el SynchronizationContext.
}

En una app WinForms o WPF la excepción aterriza en el hilo de UI — salvo que algo como Application.ThreadException (WinForms) o DispatcherUnhandledException (WPF) la intercepte, la aplicación muere. En ASP.NET Core no hay SynchronizationContext, así que la excepción se relanza en el thread pool y tumba el proceso como una excepción no manejada ordinaria.

Verás afirmado — una versión anterior de este artículo también lo afirmaba — que esta ruta llama a Environment.FailFast. No es así, y la diferencia es comprobable: Environment.FailFast omite los manejadores de AppDomain.UnhandledException, así que hice que el sample se relanzara a sí mismo como proceso hijo, dejara escapar una excepción de async void sin SynchronizationContext, y observara desde fuera. El manejador AppDomain.UnhandledException del hijo se dispara, con IsTerminating=true, antes de que el proceso muera con el banner estándar Unhandled exception del runtime y el código de salida 0xE0434352. Esa es la ruta normal de excepción no manejada — lo que además significa que un manejador global aún tiene una última oportunidad de loguear antes de que un bug de async void mate tu servicio.

El Único async void Aceptable

Los manejadores de eventos son el único caso de uso legítimo, y aún así debes envolver el cuerpo en try/catch:

// Aceptable: manejador de evento, pero protege todo el cuerpo
private async void OnButtonClick(object sender, EventArgs e)
{
    try
    {
        await LoadDataAsync();
        UpdateUI();
    }
    catch (Exception ex)
    {
        // Manejar con gracia — no podemos dejar que esto escape
        MessageBox.Show($"Error: {ex.Message}");
    }
}
⚠️

Nunca uses async void fuera de manejadores de eventos. Si un método debe retornar void (por ej., una implementación de interfaz), usa async Task en su lugar. Si la firma de la interfaz es fija, envuelve la llamada async y maneja las excepciones en línea.

Convertir async void a async Task

// Interfaz que no puedes cambiar
public interface IProcessor
{
    void Process(string data);
}
 
// Implementación que necesita trabajo async
public class DataProcessor : IProcessor
{
    // Patrón: fire-and-forget pero maneja excepciones en línea
    public void Process(string data)
    {
        // NO hagas este método async void.
        // En su lugar, inicia la tarea y adjunta manejo de errores.
        _ = ProcessInternalAsync(data).ContinueWith(
            t => _logger.LogError(t.Exception, "El procesamiento falló para {Data}", data),
            TaskContinuationOptions.OnlyOnFaulted
        );
    }
 
    private async Task ProcessInternalAsync(string data)
    {
        await Task.Delay(50);
        // trabajo async real
    }
}

AggregateException y Task.WhenAll

Task.WhenAll espera a que todas las tareas se completen sin importar los fallos — el sample verifica que una tarea sana corre hasta completarse mientras dos hermanas fallan. Cuando las tareas fallan, la propiedad Exception de la tarea combinada es un AggregateException que contiene cada fallo. Pero await lo desenvuelve: solo se relanza la primera excepción. Las demás no se destruyen — siguen en la tarea combinada — pero si nunca guardaste una referencia a ella, no tienes forma de alcanzarlas.

var tasks = new[]
{
    Task.FromException(new ArgumentException("Error A")),
    Task.FromException(new InvalidOperationException("Error B")),
    Task.FromException(new TimeoutException("Error C")),
};
 
try
{
    await Task.WhenAll(tasks); // ¡Solo se relanza ArgumentException ("Error A")!
}
catch (Exception ex)
{
    // ex es ArgumentException — Error B y Error C son inalcanzables
    // porque nunca guardamos una referencia a la tarea combinada
    Console.WriteLine(ex.Message); // "Error A"
}

Capturar Todas las Excepciones de Task.WhenAll

La solución es mantener una referencia a la tarea combinada antes de hacer await, luego inspeccionar su propiedad Exception:

public async Task ProcessAllAsync(IEnumerable<string> items)
{
    var tasks = items.Select(ProcessItemAsync).ToList();
 
    // Guardar la tarea agregada antes de hacer await
    var allTasks = Task.WhenAll(tasks);
 
    try
    {
        await allTasks;
    }
    catch
    {
        // allTasks.Exception es el AggregateException completo con TODAS las excepciones internas
        if (allTasks.Exception is not null)
        {
            foreach (var inner in allTasks.Exception.InnerExceptions)
            {
                _logger.LogError(inner, "La tarea falló: {Message}", inner.Message);
            }
        }
 
        // Relanzar o manejar según sea necesario
        throw;
    }
}

Aplanar AggregateExceptions Anidadas

Los AggregateException pueden anidarse — un AggregateException cuyas excepciones internas son a su vez AggregateExceptions. .Flatten() colapsa la jerarquía en un solo nivel. Pero sé preciso sobre cuándo ocurre realmente el anidamiento, porque yo esperaba la respuesta equivocada: anidar un Task.WhenAll dentro de otro no anida los agregados. El sample hace await de un WhenAll de un WhenAll, y la tarea externa expone las tres excepciones hoja en una lista plana.

Donde sí obtienes anidamiento genuino es en los patrones antiguos de TPL, como las tareas hijas adjuntas:

// Las tareas hijas adjuntas son la fuente clásica de agregados genuinamente anidados
var parent = Task.Factory.StartNew(() =>
{
    Task.Factory.StartNew(
        () => throw new InvalidOperationException("desde la hija adjunta"),
        TaskCreationOptions.AttachedToParent);
});
 
try
{
    parent.Wait();
}
catch (AggregateException ex)
{
    // ex.InnerExceptions[0] es OTRO AggregateException — no el error real.
    // Flatten() colapsa la jerarquía a una lista plana de InnerExceptions:
    foreach (var inner in ex.Flatten().InnerExceptions)
    {
        Console.WriteLine($"{inner.GetType().Name}: {inner.Message}"); // InvalidOperationException
    }
}

Si tu código compone tareas con WhenAll y await, rara vez necesitas Flatten() — pero llamarlo antes de iterar no cuesta nada y hace el bucle correcto para ambas formas.

Recolectar Resultados Y Errores de Task.WhenAll

A veces quieres todos los resultados exitosos Y todos los errores, no solo el primer error:

public async Task<(List<T> Results, List<Exception> Errors)> WhenAllSafeAsync<T>(
    IEnumerable<Task<T>> tasks)
{
    // Envolver cada tarea para que nunca falle — captura éxito o fallo
    var safeTasks = tasks
        .Select(async t =>
        {
            try
            {
                return (Value: await t, Error: (Exception?)null);
            }
            catch (Exception ex)
            {
                return (Value: default(T)!, Error: ex);
            }
        })
        .ToList();
 
    var outcomes = await Task.WhenAll(safeTasks);
 
    var results = outcomes
        .Where(o => o.Error is null)
        .Select(o => o.Value)
        .ToList();
 
    var errors = outcomes
        .Where(o => o.Error is not null)
        .Select(o => o.Error!)
        .ToList();
 
    return (results, errors);
}

Comportamiento de Excepciones en Task.WhenAll vs Task.WhenAny

Task.WhenAny retorna en cuanto cualquier tarea se completa — y "completarse" incluye fallar y ser cancelada, no solo tener éxito. La tarea retornada es la tarea completada en sí misma, no un nuevo wrapper, y await Task.WhenAny(...) en sí nunca lanza. El sample hace explícita la trampa poniendo a competir una tarea pendiente contra una ya fallida — gana la fallida:

var pending = Task.Delay(5_000).ContinueWith(_ => "resultado lento");
var alreadyFaulted = Task.FromException<string>(new InvalidOperationException("fallo rápido"));
 
// winner ES alreadyFaulted — WhenAny significa "primera completada", y un fallo
// se completa de inmediato. Nota que este await NO lanzó.
var winner = await Task.WhenAny(pending, alreadyFaulted);
 
// La excepción aparece solo cuando haces await al ganador en sí
try
{
    var result = await winner;
    Console.WriteLine(result);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"El ganador falló: {ex.Message}");
}
// Nota: ¡las otras tareas siguen ejecutándose! Sus excepciones no se observan
// a menos que las manejes explícitamente.
⚠️

Con Task.WhenAny, las tareas que NO ganaron siguen ejecutándose. Si fallan más tarde, esas excepciones se vuelven no observadas. Siempre adjunta manejo de errores a las tareas que no ganaron si te importan sus resultados.

Patrón: WhenAny con Limpieza

public async Task<string> RaceWithFallbackAsync(CancellationToken ct)
{
    var primary = FetchFromPrimaryAsync(ct);
    var secondary = FetchFromSecondaryAsync(ct);
 
    var first = await Task.WhenAny(primary, secondary);
 
    // Observar la otra tarea para prevenir advertencias de excepción no observada
    _ = first == primary
        ? secondary.ContinueWith(t => { /* ignorar o registrar */ }, ct)
        : primary.ContinueWith(t => { /* ignorar o registrar */ }, ct);
 
    // Lanzará si first falló
    return await first;
}

Manejar Excepciones de Tareas Paralelas Sin Perder Resultados

Un requisito común: ejecutar N tareas en paralelo, recolectar todos los resultados y reportar todos los errores juntos.

public record TaskOutcome<T>(T? Value, Exception? Error, string TaskId);
 
public async Task<IReadOnlyList<TaskOutcome<T>>> RunAllAsync<T>(
    IReadOnlyList<(string Id, Func<Task<T>> Factory)> work)
{
    var tasks = work.Select(async item =>
    {
        try
        {
            var value = await item.Factory();
            return new TaskOutcome<T>(value, null, item.Id);
        }
        catch (Exception ex)
        {
            return new TaskOutcome<T>(default, ex, item.Id);
        }
    });
 
    return await Task.WhenAll(tasks);
}
 
// Uso:
var outcomes = await RunAllAsync(new[]
{
    ("user-1", () => FetchUserAsync(1)),
    ("user-2", () => FetchUserAsync(2)),
    ("user-3", () => FetchUserAsync(3)),
});
 
var successful = outcomes.Where(o => o.Error is null).ToList();
var failed = outcomes.Where(o => o.Error is not null).ToList();
 
foreach (var failure in failed)
    _logger.LogError(failure.Error, "Falló al obtener {TaskId}", failure.TaskId);

ExceptionDispatchInfo — Relanzar con Stack Trace Original

La sentencia throw; preserva el stack trace al relanzar. Pero a veces necesitas capturar una excepción en un lugar y relanzarla en otro — para eso usa ExceptionDispatchInfo:

using System.Runtime.ExceptionServices;
 
public class ExceptionRelay
{
    private ExceptionDispatchInfo? _captured;
 
    public void CaptureException(Action work)
    {
        try
        {
            work();
        }
        catch (Exception ex)
        {
            // Captura la excepción Y su stack trace completo en este punto
            _captured = ExceptionDispatchInfo.Capture(ex);
        }
    }
 
    public void Rethrow()
    {
        // Relanza con el stack trace ORIGINAL preservado: el trace conserva los frames
        // de donde la excepción se lanzó por primera vez, con este sitio de relanzamiento
        // añadido después de ellos.
        _captured?.Throw();
    }
}

ExceptionDispatchInfo en Pipelines Async

public async Task ProcessWithRetryAsync(Func<Task> operation, int maxRetries)
{
    ExceptionDispatchInfo? lastException = null;
 
    for (int attempt = 0; attempt < maxRetries; attempt++)
    {
        try
        {
            await operation();
            return; // Éxito
        }
        catch (Exception ex) when (IsTransient(ex))
        {
            // Capture preserva el stack trace de cada intento
            lastException = ExceptionDispatchInfo.Capture(ex);
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
        }
    }
 
    // Lanza la última excepción capturada con su stack trace original
    lastException!.Throw();
}

Filtros de Excepción con when

Los filtros de excepción permiten capturar condicionalmente sin deshacer el stack, lo que preserva más información de depuración (el stack sigue intacto cuando el filtro se ejecuta):

public async Task ExecuteWithFilterAsync()
{
    try
    {
        await RiskyOperationAsync();
    }
    // Captura solo errores HTTP transitorios — los demás se propagan normalmente
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
    {
        await HandleServiceUnavailableAsync();
    }
    // Captura solo un mensaje específico — útil durante depuración
    catch (InvalidOperationException ex) when (ex.Message.Contains("timeout"))
    {
        await HandleTimeoutAsync();
    }
    // Captura todo PERO lo registra para diagnóstico sin capturar permanentemente
    catch (Exception ex) when (LogAndRethrow(ex))
    {
        // Este bloque no es alcanzable si LogAndRethrow siempre retorna false
    }
}
 
// Patrón útil: log-and-rethrow mediante filtro de excepción
// El filtro se ejecuta ANTES de que el stack se deshaga, dando un trace completo
private bool LogAndRethrow(Exception ex)
{
    _logger.LogError(ex, "Excepción en ExecuteWithFilterAsync");
    return false; // Retornar false significa que la excepción NO se captura
}
💡

Los filtros when se ejecutan antes de que el stack se deshaga. Si tu filtro siempre retorna false, la excepción se propaga con su stack completo intacto — esto es mejor que catch + throw para logging diagnóstico puro.

Combinando when con Filtros de Tipo

catch (SqlException ex) when (ex.Number == 1205) // Víctima de deadlock
{
    await RetryAfterDeadlockAsync();
}
 
catch (OperationCanceledException ex) when (ex.CancellationToken == _shutdownToken)
{
    // Solo captura cancelaciones de NUESTRO token, no de otros tokens
    _logger.LogInformation("Cerrando graciosamente");
}

Cancelación vs Estado de Tarea Fallida

La cancelación tiene un estado especial en el modelo de tareas de .NET. Un OperationCanceledException lanzado desde un método async transiciona la tarea al estado Canceled (no Faulted), y await lo relanza como OperationCanceledException. (Para cómo se cancelan los tokens en primer lugar, ver CancellationToken en C# — Patrones Prácticos.)

La regla exacta me sorprendió cuando la verifiqué. Un método async que lanza OperationCanceledException termina Canceled incluso si el token adjunto a la excepción nunca fue cancelado — la máquina de estados async trata como caso especial el tipo de excepción, no el estado del token. Un delegado síncrono en Task.Run tiene el comportamiento por defecto opuesto: lanzar un OperationCanceledException deja esa tarea Faulted, salvo que el token de la excepción sea el mismo que pasaste a Task.Run y esté realmente cancelado. Las cuatro combinaciones están verificadas en el sample.

public async Task DemonstrateTaskStatesAsync()
{
    var cts = new CancellationTokenSource();
    cts.Cancel();
 
    var canceledTask = Task.FromCanceled(cts.Token);
    var faultedTask = Task.FromException(new InvalidOperationException("boom"));
 
    Console.WriteLine(canceledTask.Status);  // Canceled
    Console.WriteLine(faultedTask.Status);   // Faulted
 
    try
    {
        await canceledTask;
    }
    catch (OperationCanceledException ex)
    {
        // ex.CancellationToken está poblado — puedes verificar qué token canceló
        Console.WriteLine($"Cancelado por token: {ex.CancellationToken == cts.Token}");
    }
}

Distinguir Cancelación de Otras Excepciones

public async Task<Result> ProcessWithCancellationAsync(
    string input, 
    CancellationToken ct)
{
    try
    {
        return await DoProcessAsync(input, ct);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Esperado — el llamador solicitó cancelación. No es un error.
        _logger.LogDebug("Procesamiento cancelado para input {Input}", input);
        return Result.Cancelled;
    }
    catch (OperationCanceledException ex)
    {
        // Un token DIFERENTE fue cancelado — ESTO SÍ es un error inesperado
        _logger.LogWarning(ex, "Cancelación inesperada");
        throw;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error inesperado procesando {Input}", input);
        throw;
    }
}
💡

Siempre verifica ct.IsCancellationRequested en tu bloque catch de OperationCanceledException. Esto distingue "el llamador nos pidió parar" (normal) de "alguna dependencia expiró" (inesperado), lo cual podría ser un bug.

TaskCanceledException vs OperationCanceledException

TaskCanceledException hereda de OperationCanceledException. HttpClient lanza TaskCanceledException tanto para timeouts como para cancelación explícita. Desde .NET 5, la expiración de HttpClient.Timeout lleva además una TimeoutException interna — el sample de CancellationToken lo verifica contra un servidor local deliberadamente congelado. Para tus propias fuentes de timeout, distingue por el token:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
try
{
    var response = await _httpClient.GetAsync(url, cts.Token);
}
catch (TaskCanceledException ex)
{
    if (cts.Token.IsCancellationRequested)
    {
        // Pedimos cancelar — o nuestro timeout de 30s se disparó
        throw new TimeoutException("La petición expiró", ex);
    }
    // Token interno de HttpClient — normalmente no deberías llegar aquí
    throw;
}

Manejador Global de Excepciones de Tareas No Observadas

Cuando una tarea fallida es recolectada por el GC sin que su excepción haya sido observada, TaskScheduler.UnobservedTaskException se dispara. Este es tu manejador de último recurso para excepciones de tareas fire-and-forget:

// Program.cs — registrar al inicio
TaskScheduler.UnobservedTaskException += (sender, args) =>
{
    // args.Exception es el AggregateException que envuelve las excepciones no observadas
    foreach (var ex in args.Exception.InnerExceptions)
    {
        _logger.LogCritical(ex, "Excepción de tarea no observada");
    }
 
    // Llama a SetObserved() para prevenir que la excepción sea relanzada
    // por el hilo del finalizador (lo que crashearía el proceso en versiones antiguas de .NET)
    args.SetObserved();
};
⚠️

En .NET 4.0, las excepciones de tareas no observadas crasheaban el proceso. A partir de .NET 4.5, se silencian por defecto — pero aún deberías manejarlas para detectar bugs. El timing del hilo del finalizador es no determinístico, por lo que este manejador se dispara impredeciblemente durante los ciclos del GC, no inmediatamente cuando ocurre la excepción.

AppDomain.UnhandledException — La Red de Seguridad Final

Para excepciones verdaderamente no manejadas (no en tareas), usa AppDomain.CurrentDomain.UnhandledException. Esto se dispara después de que el proceso ya está condenado — puedes registrar pero no prevenir la terminación:

AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
    var ex = args.ExceptionObject as Exception;
    // args.IsTerminating es true cuando el runtime está por abortar
    _logger.LogCritical(ex, "Excepción fatal no manejada. IsTerminating={IsTerminating}", 
        args.IsTerminating);
 
    // Vaciar logs síncronamente — el proceso está terminando
    Log.CloseAndFlush();
};

Program.cs: Registrando Todos los Manejadores Globales

var builder = WebApplication.CreateBuilder(args);
// ... registro de servicios
 
var app = builder.Build();
 
// Manejadores globales — registrar antes de app.Run()
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
 
app.Run();
 
static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    var logger = /* resolver desde DI o usar logger estático */;
    logger.LogCritical(e.ExceptionObject as Exception, "Excepción no manejada");
}
 
static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
    var logger = /* resolver desde DI o usar logger estático */;
    logger.LogError(e.Exception, "Excepción de tarea no observada");
    e.SetObserved();
}

Middleware de Excepciones en ASP.NET Core

La arquitectura basada en pipeline de ASP.NET Core es ideal para el manejo centralizado de excepciones. El middleware personalizado te da control total:

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;
 
    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            // El cliente se desconectó — no es un error, no se necesita respuesta
            _logger.LogDebug("Petición cancelada por el cliente: {Path}", context.Request.Path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Excepción no manejada para {Method} {Path}",
                context.Request.Method,
                context.Request.Path);
 
            await WriteErrorResponseAsync(context, ex);
        }
    }
 
    private static async Task WriteErrorResponseAsync(HttpContext context, Exception ex)
    {
        // No sobreescribir una respuesta que ya comenzó a transmitirse
        if (context.Response.HasStarted) return;
 
        context.Response.StatusCode = ex switch
        {
            ArgumentException => StatusCodes.Status400BadRequest,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            KeyNotFoundException => StatusCodes.Status404NotFound,
            _ => StatusCodes.Status500InternalServerError
        };
 
        context.Response.ContentType = "application/json";
 
        var response = new
        {
            error = ex.Message,
            traceId = context.TraceIdentifier
        };
 
        await context.Response.WriteAsJsonAsync(response);
    }
}

Registro y UseExceptionHandler

// Program.cs
app.UseMiddleware<ExceptionHandlingMiddleware>();
 
// O usa el UseExceptionHandler integrado para casos simples:
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
        var ex = exceptionFeature?.Error;
 
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";
 
        await context.Response.WriteAsJsonAsync(new
        {
            error = "Ocurrió un error inesperado",
            traceId = context.TraceIdentifier
        });
    });
});

Manejador de Excepciones con IExceptionHandler en Minimal APIs

.NET 8 introdujo IExceptionHandler para manejo estructurado de excepciones que se integra con el contenedor DI:

public class AppExceptionHandler : IExceptionHandler
{
    private readonly ILogger<AppExceptionHandler> _logger;
 
    public AppExceptionHandler(ILogger<AppExceptionHandler> logger)
    {
        _logger = logger;
    }
 
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Excepción ocurrida: {Message}", exception.Message);
 
        var (statusCode, title) = exception switch
        {
            ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request"),
            KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found"),
            UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
            _ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
        };
 
        var problemDetails = new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = exception.Message,
            Instance = httpContext.Request.Path
        };
 
        httpContext.Response.StatusCode = statusCode;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
 
        // Retornar true = excepción manejada, false = pasar al siguiente manejador
        return true;
    }
}
 
// Registro en Program.cs:
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();
 
app.UseExceptionHandler();

Patrones Prácticos — Todo en Conjunto

Llamada HTTP Resiliente con Manejo Completo de Excepciones

public class ResilientApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<ResilientApiClient> _logger;
 
    public async Task<T?> GetAsync<T>(string endpoint, CancellationToken ct = default)
    {
        const int MaxRetries = 3;
        ExceptionDispatchInfo? lastCapture = null;
 
        for (int attempt = 1; attempt <= MaxRetries; attempt++)
        {
            try
            {
                using var response = await _httpClient.GetAsync(endpoint, ct);
                response.EnsureSuccessStatusCode();
                return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                // El llamador canceló — dejar de reintentar inmediatamente
                _logger.LogDebug("Petición cancelada: {Endpoint}", endpoint);
                throw;
            }
            catch (HttpRequestException ex) when (IsRetryable(ex))
            {
                _logger.LogWarning(ex, "Intento {Attempt}/{Max} falló para {Endpoint}",
                    attempt, MaxRetries, endpoint);
 
                // Preservar stack trace para el relanzamiento final
                lastCapture = ExceptionDispatchInfo.Capture(ex);
 
                if (attempt < MaxRetries)
                    await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct);
            }
        }
 
        // Todos los reintentos agotados — relanzar con stack trace original
        lastCapture!.Throw();
        return default; // Inalcanzable, pero satisface al compilador
    }
 
    private static bool IsRetryable(HttpRequestException ex) =>
        ex.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests
        || ex.StatusCode is null; // Fallos a nivel de red
}

Procesamiento en Lote con Aislamiento de Errores

public async Task<BatchResult<T>> ProcessBatchAsync<T>(
    IReadOnlyList<string> itemIds,
    Func<string, CancellationToken, Task<T>> processor,
    int concurrency = 10,
    CancellationToken ct = default)
{
    using var semaphore = new SemaphoreSlim(concurrency);
    var results = new ConcurrentBag<(string Id, T? Value, Exception? Error)>();
 
    var tasks = itemIds.Select(async id =>
    {
        await semaphore.WaitAsync(ct);
        try
        {
            var value = await processor(id, ct);
            results.Add((id, value, null));
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            throw; // Propagar cancelación
        }
        catch (Exception ex)
        {
            // Aislar el fallo — los otros elementos continúan procesándose
            results.Add((id, default, ex));
        }
        finally
        {
            semaphore.Release();
        }
    });
 
    await Task.WhenAll(tasks);
 
    return new BatchResult<T>(
        Successes: results.Where(r => r.Error is null)
                          .Select(r => (r.Id, r.Value!))
                          .ToList(),
        Failures: results.Where(r => r.Error is not null)
                         .Select(r => (r.Id, r.Error!))
                         .ToList()
    );
}
 
public record BatchResult<T>(
    IReadOnlyList<(string Id, T Value)> Successes,
    IReadOnlyList<(string Id, Exception Error)> Failures
);

Resumen

EscenarioPatrón
Excepción async normaltry/catch alrededor de await — funciona como se espera
Excepción en async voidEnvuelve con try/catch dentro del manejador; evita async void por completo
Todas las excepciones de Task.WhenAllMantén referencia a la tarea combinada; inspecciona .Exception.InnerExceptions
AggregateException anidadaLlama a .Flatten() antes de iterar
Preservar stack trace al relanzarUsa throw; o ExceptionDispatchInfo.Capture().Throw()
Catch condicionalFiltro de excepción: catch (Ex e) when (condición)
Cancelación vs fallocatch (OperationCanceledException) when (ct.IsCancellationRequested)
Red de seguridad fire-and-forgetTaskScheduler.UnobservedTaskException
Red de seguridad a nivel de procesoAppDomain.CurrentDomain.UnhandledException
Manejo centralizado en ASP.NET CoreMiddleware personalizado o IExceptionHandler (.NET 8+)

Cada afirmación de comportamiento de arriba está verificada por samples/async-exception-handling-csharp — clónalo y ejecuta dotnet run si quieres ver alguna fallar en un runtime futuro.

Dos temas vecinos valen la pena como siguiente lectura. CancellationToken en C# — Patrones Prácticos cubre el lado productor de la distinción Canceled-vs-Faulted — tokens enlazados, timeouts, y saber de quién es la cancelación que capturaste. Y cuando una excepción nunca aparece porque el código está atascado en lugar de fallido, eso suele ser un deadlock de sync-over-async, que necesita herramientas completamente distintas.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados