Start Debugging

Correção: AggregateException "One or more errors occurred" ao aguardar Task.WhenAll em C#

await Task.WhenAll relança apenas uma das falhas. Guarde a task do WhenAll em uma variável e leia Exception.InnerExceptions para ver todos os erros, e não apenas um.

Se várias tasks de um Task.WhenAll falham, a task retornada termina com falha em uma AggregateException cuja mensagem é “One or more errors occurred”, mas o await a desembrulha e relança exatamente uma das exceções internas. Todas as outras falhas são descartadas silenciosamente e nunca chegam ao seu bloco catch. A correção é guardar em uma variável local a task que Task.WhenAll retorna, aguardá-la dentro de um try e ler whenAll.Exception.InnerExceptions no catch para obter todas elas. Se você está vendo o tipo AggregateException literal em um catch, é porque está bloqueando com .Wait() ou .Result em vez de aguardar, o que é um problema separado e pior. Verificado em .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14), com o comportamento de runtime medido em .NET 10.0.5; o código de runtime relevante é idêntico byte a byte nos branches release/10.0 e main.

O erro em contexto

Bloquear na task do WhenAll entrega o invólucro diretamente:

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)

Aguardá-la não devolve nenhuma AggregateException, apenas uma das exceções internas:

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

As duas são a mesma situação de fundo. Essas duas formas são o motivo de as buscas por esse erro caírem em conselhos contraditórios.

Por que o await esconde todas as falhas menos uma

A documentação de Task.WhenAll diz que a task termina no estado Faulted “onde suas exceções conterão a agregação do conjunto de exceções desembrulhadas de cada uma das tasks fornecidas”. Essa agregação vive na propriedade Exception da task retornada, e ela realmente contém todas as falhas.

A perda acontece uma camada acima. O await é especificado para relançar a exceção de uma task já desembrulhada, então você captura HttpRequestException em vez de AggregateException quando uma única task falha. Esse desembrulho é o padrão correto: quase toda API assíncrona produz no máximo um erro, e escrever catch (AggregateException ae) { ae.InnerException ... } em volta de cada await seria insuportável. Task.WhenAll é a principal API onde essa suposição quebra, e o awaiter não tem como sinalizar “foram quatro”. Ele pega um exception dispatch info da lista e o relança. Isso foi levantado como dotnet/runtime#31494 e novamente como dotnet/runtime#47605, pedindo um await opcional que propagasse o agregado inteiro. Nenhum dos dois foi lançado, então a alternativa abaixo continua sendo a resposta.

O corolário importa para suas cláusulas catch: depois de await Task.WhenAll(...), um catch (AggregateException) nunca dispara. Se você escreveu um, ele é código morto e a exceção real passa direto por ele.

Reprodução 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
}

Entram três falhas, sai uma. Nada dentro do bloco catch consegue recuperar as outras duas, porque a única referência ao agregado era o temporário que Task.WhenAll retornou e o await consumiu.

Correção 1: guarde a task do WhenAll e leia InnerExceptions

Esta é a correção para a esmagadora maioria dos casos, e a única mudança é uma variável 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 é não nulo exatamente quando whenAll.Status == TaskStatus.Faulted, e sua coleção InnerExceptions guarda uma entrada por task que falhou, cada uma com o stack trace original intacto. O catch vazio com um throw preserva o comportamento existente para quem chama (continua vendo uma única exceção desembrulhada) e ao mesmo tempo dá fidelidade total no log.

Dois detalhes tornam isso seguro de aplicar mecanicamente. Primeiro, não coloque a chamada de Task.WhenAll(...) dentro do try: quem lança é o await, não a chamada, mas deixar a atribuição fora torna a variável visível no catch. Segundo, use catch ou catch (Exception), não catch (AggregateException), pelo motivo da seção anterior.

Correção 2: nunca deixe a task do WhenAll falhar

Se o seu fan-out é um lote em que falha parcial é normal, o desenho mais limpo é impedir que exceções escapem das tasks individuais. Envolva cada unidade de trabalho para que ela devolva o resultado em vez de lançar:

// .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 agora sempre chega ao fim, então não há agregado a desempacotar, nenhum filtro de exceção para acertar, e a associação entre cada falha e o item que a causou sobrevive. Essa associação é justamente o que a Correção 1 não consegue dar: InnerExceptions é uma lista plana de exceções sem referência de volta à task que as produziu. Quando você precisa repetir as falhas ou reportar quais registros foram rejeitados, use este formato.

O custo é que um erro genuinamente fatal deixa de se propagar sozinho. Decida explicitamente o que fazer quando results contiver erros, ou você terá construído uma falha silenciosa.

Correção 3: relance o agregado inteiro de propósito

Quando quem chama realmente deve ver todas as falhas, relance o agregado em vez de deixar o await escolher uma. ExceptionDispatchInfo preserva os stack traces originais:

// .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();
    }
}

Quem chamar esse helper recebe uma AggregateException com todas as exceções internas, que é o que as pessoas geralmente querem quando escrevem catch (AggregateException) depois de um await. Use isso em uma fronteira onde uma única operação lógica realmente falhou de várias maneiras ao mesmo tempo, como uma importação em lote que precisa reportar todos os erros de validação. Não faça disso o seu padrão: empurra o tratamento de AggregateException para todos os chamadores, que é exatamente o problema de ergonomia que o desembrulho do await veio eliminar.

Qual exceção o await realmente lança?

É aqui que a maioria das respostas existentes erra, inclusive as que dizem “a primeira exceção”. Depende de qual sobrecarga você chamou, e a diferença é determinística.

// .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

O Task.WhenAll não genérico ordena InnerExceptions por tempo de conclusão. O genérico Task.WhenAll<TResult> as ordena por posição do argumento. Ambos lançam InnerExceptions[0]. Esse resultado se manteve estável em execuções repetidas no .NET 10.0.5.

A causa está visível no código-fonte do runtime. As duas promises estão em Task.cs. A WhenAllPromise não genérica deliberadamente não retém o array de entrada; seu callback de conclusão Invoke acrescenta cada task que falhou a uma lista conforme ela termina, e depois percorre essa lista:

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

A WhenAllPromise<T> genérica mantém o array porque precisa produzir os resultados T[] em ordem, e o percorre 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()); }
    ...
}

Essa divergência apareceu no .NET 8 e foi reportada como dotnet/runtime#93504 depois que o caminho não genérico foi reescrito por motivos de alocação. Foi fechada como “not planned” e não está na documentação de mudanças incompatíveis. Na prática: nunca escreva código que dependa de qual falha aflora de um await Task.WhenAll. Leia a lista inteira, conforme a Correção 1.

O cancelamento desaparece quando algo falha

A outra perda silenciosa é o cancelamento. Se uma task é cancelada e outra falha, a cancelada não contribui com 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)

As duas implementações da promise registram canceledTask em uma variável local separada e só chamam TrySetCanceled quando a lista de exceções está vazia, o que bate com a regra documentada: falha ganha de cancelamento, e cancelamento ganha de sucesso. Se nada falhar e pelo menos uma task for cancelada, a task do WhenAll termina em Canceled, sua propriedade Exception é null e o await lança uma TaskCanceledException. Código que faz whenAll.Exception!.InnerExceptions sem checar Status vai bater em uma NullReferenceException exatamente nesse caso, então proteja-o:

// .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 um cancelamento genuíno de um timeout disfarçado de cancelamento é uma armadilha à parte, coberta em por que o HttpClient lança TaskCanceledException.

Armadilhas e variantes

O modelo mental de uma linha: Task.WhenAll coleta todas as falhas fielmente, e o await é o passo que perde informação. Dê um nome à task retornada e nada se perde.

Relacionados

Fontes

Comments

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

< Voltar