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).
| Demo | Verificado |
|---|---|
| Relanzamiento en await | await lanza el tipo de excepción original, no AggregateException; la tarea termina Faulted; task.Exception es el wrapper AggregateException |
| Throw antes del primer await | llamar 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 + SyncContext | un try/catch alrededor de la llamada async void no captura nada; un SynchronizationContext personalizado recibe la excepción vía Post |
| async void sin SyncContext | el 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.WhenAll | await relanza solo la primera excepción; la tarea sana aún corrió hasta completarse; Exception.InnerExceptions de la tarea combinada contiene cada fallo |
| Agregados anidados | las tareas hijas adjuntas anidan AggregateExceptions y Flatten() las colapsa; WhenAll-de-WhenAll se mantiene plano |
Task.WhenAny | una 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ón | un filtro when que retorna false observa la excepción sin capturarla; la misma instancia sigue propagándose |
| Canceled vs Faulted | un 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 traces | throw; y ExceptionDispatchInfo conservan el frame original; throw ex; lo borra |
| Excepciones no observadas | TaskScheduler.UnobservedTaskException se dispara durante la recolección de basura, no al momento del fallo |

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
| Escenario | Patrón |
|---|---|
| Excepción async normal | try/catch alrededor de await — funciona como se espera |
| Excepción en async void | Envuelve con try/catch dentro del manejador; evita async void por completo |
Todas las excepciones de Task.WhenAll | Mantén referencia a la tarea combinada; inspecciona .Exception.InnerExceptions |
AggregateException anidada | Llama a .Flatten() antes de iterar |
| Preservar stack trace al relanzar | Usa throw; o ExceptionDispatchInfo.Capture().Throw() |
| Catch condicional | Filtro de excepción: catch (Ex e) when (condición) |
| Cancelación vs fallo | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| Red de seguridad fire-and-forget | TaskScheduler.UnobservedTaskException |
| Red de seguridad a nivel de proceso | AppDomain.CurrentDomain.UnhandledException |
| Manejo centralizado en ASP.NET Core | Middleware 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.