Start Debugging

Solución: AggregateException "One or more errors occurred" al esperar Task.WhenAll en C#

await Task.WhenAll relanza solo uno de los fallos. Guarda la tarea de WhenAll en una variable y lee su Exception.InnerExceptions para ver todos los errores, no uno solo.

Si varias tareas de un Task.WhenAll fallan, la tarea devuelta termina en estado de fallo con una AggregateException cuyo mensaje es “One or more errors occurred”, pero await la desenvuelve y relanza exactamente una de las excepciones internas. Todos los demás fallos se descartan en silencio y nunca llegan a tu bloque catch. La solución es guardar en una variable local la tarea que devuelve Task.WhenAll, esperarla dentro de un try y leer whenAll.Exception.InnerExceptions en el catch para obtenerlos todos. Si estás viendo el tipo AggregateException literal en un catch, es que estás bloqueando con .Wait() o .Result en lugar de esperar, lo cual es un problema distinto y peor. Verificado en .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14), con el comportamiento del runtime medido en .NET 10.0.5; el código del runtime relevante es idéntico byte a byte en las ramas release/10.0 y main.

El error en contexto

Bloquear sobre la tarea de WhenAll te entrega el envoltorio directamente:

Unhandled exception. System.AggregateException: One or more errors occurred. (Connection refused) (The operation has timed out.)
 ---> System.Net.Http.HttpRequestException: Connection refused
   at OrderSync.FetchAsync(String url)
   --- End of inner exception stack trace ---
   at System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCanceledExceptions)
   at System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken)

Esperarla no te da ninguna AggregateException, solo una de las excepciones internas:

Unhandled exception. System.Net.Http.HttpRequestException: Connection refused
   at OrderSync.FetchAsync(String url)
   at OrderSync.SyncAllAsync()

Ambas son la misma situación de fondo. Esas dos formas son la razón de que las búsquedas de este error terminen en consejos contradictorios.

Por qué await oculta todos los fallos menos uno

La documentación de Task.WhenAll dice que la tarea termina en estado Faulted “donde sus excepciones contendrán la agregación del conjunto de excepciones desenvueltas de cada una de las tareas suministradas”. Esa agregación vive en la propiedad Exception de la tarea devuelta, y realmente contiene todos los fallos.

La pérdida ocurre una capa más arriba. await está especificado para relanzar la excepción de una tarea ya desenvuelta, de modo que capturas HttpRequestException en lugar de AggregateException cuando falla una sola tarea. Ese desenvoltorio es el comportamiento correcto por defecto: casi toda API asíncrona produce como mucho un error, y escribir catch (AggregateException ae) { ae.InnerException ... } alrededor de cada await sería insoportable. Task.WhenAll es la API principal donde esa suposición se rompe, y el awaiter no tiene forma de indicar “hubo cuatro”. Toma un exception dispatch info de la lista y lo relanza. Esto se planteó como dotnet/runtime#31494 y de nuevo como dotnet/runtime#47605, pidiendo un await opcional que propagara el agregado completo. Ninguno llegó a publicarse, así que la alternativa de abajo sigue siendo la respuesta.

El corolario importa para tus cláusulas catch: después de await Task.WhenAll(...), un catch (AggregateException) nunca se activa. Si escribiste uno, es código muerto y la excepción real pasa de largo.

Reproducción mínima

// .NET 11, C# 14
static async Task FailAsync(string message)
{
    await Task.Delay(10);
    throw new InvalidOperationException(message);
}

try
{
    await Task.WhenAll(FailAsync("first"), FailAsync("second"), FailAsync("third"));
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);   // prints one message, not three
}

Entran tres fallos, sale uno. Nada dentro del bloque catch puede recuperar los otros dos, porque la única referencia al agregado era el temporal que devolvió Task.WhenAll y que await consumió.

Solución 1: guarda la tarea de WhenAll y lee InnerExceptions

Esta es la solución para la inmensa mayoría de los casos, y el único cambio es una variable local:

// .NET 11, C# 14
Task whenAll = Task.WhenAll(FailAsync("first"), FailAsync("second"), FailAsync("third"));

try
{
    await whenAll;
}
catch
{
    // whenAll.Exception is the AggregateException the await threw away
    foreach (Exception inner in whenAll.Exception!.InnerExceptions)
    {
        _logger.LogError(inner, "Sync step failed");
    }
    throw;
}

whenAll.Exception no es nulo exactamente cuando whenAll.Status == TaskStatus.Faulted, y su colección InnerExceptions guarda una entrada por cada tarea fallida, cada una con su traza de pila original intacta. El catch vacío con un throw preserva el comportamiento existente para quien llama (sigue viendo una única excepción desenvuelta) mientras te da fidelidad total en el log.

Dos detalles hacen que esto sea seguro de aplicar de forma mecánica. Primero, no metas la llamada a Task.WhenAll(...) dentro del try: quien lanza es el await, no la llamada, pero dejar la asignación fuera hace que la variable sea visible en el catch. Segundo, usa catch o catch (Exception), no catch (AggregateException), por la razón de la sección anterior.

Solución 2: evita que la tarea de WhenAll falle nunca

Si tu fan-out es un lote donde el fallo parcial es normal, el diseño más limpio es impedir que las excepciones escapen de las tareas individuales. Envuelve cada unidad de trabajo para que devuelva su resultado en vez de lanzar:

// .NET 11, C# 14
static async Task<(int Id, Exception? Error)> RunSafeAsync(int id, Func<Task> work)
{
    try
    {
        await work();
        return (id, null);
    }
    catch (Exception ex)
    {
        return (id, ex);
    }
}

var results = await Task.WhenAll(orders.Select(o => RunSafeAsync(o.Id, () => SyncAsync(o))));

foreach (var (id, error) in results.Where(r => r.Error is not null))
{
    _logger.LogError(error, "Order {OrderId} failed", id);
}

Task.WhenAll ahora siempre se completa, así que no hay agregado que desempaquetar, ningún filtro de excepciones que acertar, y sobrevive la asociación entre cada fallo y el elemento que lo causó. Esa asociación es justo lo que la Solución 1 no puede darte: InnerExceptions es una lista plana de excepciones sin referencia de vuelta a la tarea que las produjo. Cuando necesitas reintentar los fallos o informar qué registros fueron rechazados, usa esta forma.

El coste es que un error genuinamente fatal ya no se propaga solo. Decide explícitamente qué hacer cuando results contenga errores, o habrás construido un fallo silencioso.

Solución 3: relanza el agregado completo a propósito

Cuando quien llama realmente debe ver todos los fallos, relanza el agregado en vez de dejar que await elija uno. ExceptionDispatchInfo conserva las trazas de pila originales:

// .NET 11, C# 14
using System.Runtime.ExceptionServices;

public static async Task WhenAllWithAggregateAsync(IEnumerable<Task> tasks)
{
    Task whenAll = Task.WhenAll(tasks);
    try
    {
        await whenAll;
    }
    catch
    {
        ExceptionDispatchInfo.Capture(whenAll.Exception!).Throw();
    }
}

Quien llame a ese helper recibe una AggregateException con todas las excepciones internas, que es lo que la gente suele buscar cuando escribe catch (AggregateException) después de un await. Úsalo en una frontera donde una única operación lógica realmente falló de varias formas a la vez, como una importación por lotes que debe reportar todos los errores de validación. No lo conviertas en tu comportamiento por defecto: empuja el manejo de AggregateException a todos los que llaman, que es exactamente el problema de ergonomía que el desenvoltorio de await vino a eliminar.

¿Qué excepción lanza realmente await?

Aquí es donde la mayoría de las respuestas existentes se equivocan, incluidas las que dicen “la primera excepción”. Depende de qué sobrecarga llamaste, y la diferencia es determinista.

// .NET 10.0.5, C# 14 -- three tasks that fail at staggered times,
// slowest one first in argument order
static async Task FailAfterAsync(int ms, string message)
{
    await Task.Delay(ms);
    throw new InvalidOperationException(message);
}

static async Task<int> FailAfterIntAsync(int ms, string message)
{
    await Task.Delay(ms);
    throw new InvalidOperationException(message);
}

// non-generic overload -> Task
var nonGeneric = Task.WhenAll(
    FailAfterAsync(150, "index0-slow"),
    FailAfterAsync(80,  "index1-medium"),
    FailAfterAsync(10,  "index2-fast"));
// await throws:    index2-fast
// InnerExceptions: index2-fast, index1-medium, index0-slow

// generic overload -> Task<int[]>
var generic = Task.WhenAll(
    FailAfterIntAsync(150, "index0-slow"),
    FailAfterIntAsync(80,  "index1-medium"),
    FailAfterIntAsync(10,  "index2-fast"));
// await throws:    index0-slow
// InnerExceptions: index0-slow, index1-medium, index2-fast

El Task.WhenAll no genérico ordena InnerExceptions por tiempo de finalización. El genérico Task.WhenAll<TResult> las ordena por posición del argumento. Ambos lanzan InnerExceptions[0]. Ese resultado fue estable a lo largo de ejecuciones repetidas en .NET 10.0.5.

La causa se ve en el código fuente del runtime. Ambas promesas están en Task.cs. La WhenAllPromise no genérica deliberadamente no conserva el array de entrada; su callback de finalización Invoke añade cada tarea fallida a una lista a medida que se completa, y luego recorre esa lista:

// dotnet/runtime, Task.WhenAllPromise.Invoke
if (failedOrCanceled is List<Task> list)
{
    foreach (Task task in list) { HandleTask(task); }
}

La WhenAllPromise<T> genérica conserva el array porque tiene que producir los resultados T[] en orden, y lo recorre por índice:

// dotnet/runtime, Task.WhenAllPromise<T>.Invoke
for (int i = 0; i < m_tasks.Length; i++)
{
    Task<T>? task = m_tasks[i];
    if (task.IsFaulted) { observedExceptions ??= new(); observedExceptions.AddRange(task.GetExceptionDispatchInfos()); }
    ...
}

Esta divergencia apareció en .NET 8 y se reportó como dotnet/runtime#93504 después de que la ruta no genérica se reescribiera por motivos de asignación de memoria. Se cerró como “not planned” y no está en la documentación de cambios incompatibles. En la práctica: nunca escribas código que dependa de qué fallo aflora desde un await Task.WhenAll. Lee la lista completa, según la Solución 1.

La cancelación desaparece cuando algo falla

La otra pérdida silenciosa es la cancelación. Si una tarea se cancela y otra falla, la cancelada no aporta nada:

// .NET 10.0.5
var mixed = Task.WhenAll(canceledTask, faultingTask);
try { await mixed; } catch (Exception ex) { /* InvalidOperationException */ }

// mixed.Status                          -> Faulted
// mixed.Exception.InnerExceptions.Count -> 1   (the cancellation is gone)

Ambas implementaciones de la promesa registran canceledTask en una variable local aparte y solo llaman a TrySetCanceled cuando la lista de excepciones está vacía, lo que coincide con la regla documentada: el fallo gana a la cancelación, y la cancelación gana al éxito. Si nada falla y al menos una tarea se cancela, la tarea de WhenAll termina en Canceled, su propiedad Exception es null y await lanza una TaskCanceledException. El código que hace whenAll.Exception!.InnerExceptions sin comprobar Status provocará una NullReferenceException exactamente en ese caso, así que protégelo:

// .NET 11, C# 14
catch (Exception ex)
{
    if (whenAll.Exception is { } aggregate)
    {
        foreach (var inner in aggregate.InnerExceptions) _logger.LogError(inner, "Step failed");
    }
    else
    {
        _logger.LogWarning(ex, "Batch was canceled");
    }
    throw;
}

Distinguir una cancelación genuina de un timeout disfrazado de cancelación es su propia trampa, cubierta en por qué HttpClient lanza TaskCanceledException.

Trampas y variantes

El modelo mental de una línea: Task.WhenAll recoge fielmente todos los fallos, y await es el paso que pierde información. Dale un nombre a la tarea devuelta y no se pierde nada.

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver