//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 23 min read

Tratamento de Exceções Async em C# — O Guia Completo

Trate exceções em código async C#: try/catch com await, AggregateException do Task.WhenAll, armadilhas do async void, filtros de exceção e handlers globais para tarefas não observadas.

#csharp#async#dotnet

O código async em C# parece simples até uma exceção escapar em um método fire-and-forget, ou você perder 4 de 5 erros do Task.WhenAll porque capturou apenas o primeiro. Este guia cobre todos os padrões de tratamento de exceções que você precisa para código async em produção.

Cada Afirmação Aqui É Verificada, Não Narrada

O roteamento de exceções é completamente observável: qual bloco catch executou, que tipo saiu do await, em que estado a task terminou, o que um stack trace contém. Então, em vez de pedir que você confie na prosa, transformei as afirmações deste artigo em asserções: samples/async-exception-handling-csharp é um projeto de console em que cada linha de saída é uma verificação que passa. A única afirmação que não pode ser verificada de dentro de um processo — "uma exceção de async void o mata" — é provada relançando o mesmo executável como processo filho e inspecionando o que volta.

Escrever o sample corrigiu duas coisas que eu mesmo tinha errado: a afirmação amplamente repetida de que exceções de async void no ASP.NET Core passam por Environment.FailFast (não passam — veja abaixo), e minha suposição de que uma task só termina Canceled quando seu token foi realmente cancelado (também não).

DemoVerificado
Relançamento no awaitawait lança o tipo de exceção original, não AggregateException; a task termina Faulted; task.Exception é o wrapper AggregateException
Throw antes do primeiro awaitchamar o método async não lança; a exceção aparece no await; um wrapper de validação não-async lança no ponto de chamada
async void + SyncContextum try/catch em volta da chamada async void não captura nada; um SynchronizationContext personalizado recebe a exceção via Post
async void sem SyncContexto processo filho morre pela rota normal de exceção não tratada — AppDomain.UnhandledException dispara com IsTerminating=true, algo que Environment.FailFast teria pulado
Task.WhenAllawait relança apenas a primeira exceção; a task saudável ainda rodou até completar; Exception.InnerExceptions da task combinada contém cada falha
Agregados aninhadostasks filhas anexadas aninham AggregateExceptions e Flatten() as colapsa; WhenAll-de-WhenAll permanece plano
Task.WhenAnyuma task já falhada vence a corrida; await Task.WhenAny em si nunca lança; a exceção aparece ao fazer await do vencedor
Filtros de exceçãoum filtro when que retorna false observa a exceção sem capturá-la; a mesma instância continua se propagando
Canceled vs Faultedum método async que lança OperationCanceledException termina Canceled mesmo com um token nunca cancelado; um delegate síncrono em Task.Run termina Faulted a menos que o token corresponda
Stack tracesthrow; e ExceptionDispatchInfo preservam o frame original; throw ex; o apaga
Exceções não observadasTaskScheduler.UnobservedTaskException dispara durante a coleta de lixo, não no momento da falha
Saída de console do sample: 36 verificações passando em 11 demos — await relança o tipo de exceção original enquanto task.Exception o envolve; um throw antes do primeiro await aparece apenas no await; exceções de async void caem no SynchronizationContext e, sem um, matam um processo filho via AppDomain.UnhandledException com IsTerminating=true em vez de Environment.FailFast; await Task.WhenAll relança apenas a primeira exceção enquanto a task combinada retém ambas; tasks filhas anexadas aninham AggregateExceptions mas WhenAll aninhado permanece plano; uma task já falhada vence Task.WhenAny; um filtro when observa sem capturar; métodos async que lançam OperationCanceledException terminam Canceled mesmo com um token nunca cancelado enquanto delegates síncronos de Task.Run terminam Faulted a menos que o token corresponda; throw; e ExceptionDispatchInfo mantêm o frame original enquanto throw ex o apaga; e TaskScheduler.UnobservedTaskException dispara durante o GC. Todas as verificações passaram.
A execução do sample: 36 asserções em 11 demos, terminando com o resumo de uma linha — exceções nunca desaparecem; são armazenadas, postadas ou finalizadas.

Como as Exceções se Propagam em Métodos Async

Quando você faz await em uma task com falha, a exceção armazenada dentro dela é relançada no ponto do await. Isso significa que blocos try/catch comuns funcionam exatamente como esperado:

public async Task<string> FetchDataAsync(string url)
{
    try
    {
        using var client = new HttpClient();
        // Se GetStringAsync lançar, a exceção é capturada na Task retornada.
        // Quando fazemos await, a exceção é relançada aqui.
        return await client.GetStringAsync(url);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Requisição HTTP falhou para {Url}", url);
        throw; // Relançar para preservar o stack trace original
    }
}

O compilador transforma seu método async em uma máquina de estados. Quando uma exceção ocorre dentro dessa máquina, ela é capturada e armazenada como falha da Task. A exceção aparece quando o chamador faz await na task.

O Que Acontece Sem await

Se você nunca fizer await em uma task, a exceção é silenciada — ela se torna uma exceção não observada:

// RUIM: A exceção de ProcessAsync() nunca é observada.
// Sem crash, sem log, nada. O bug é invisível.
_ = ProcessAsync();
 
// MELHOR: No mínimo, adicione uma continuação para registrar falhas
Task.Run(ProcessAsync).ContinueWith(t =>
{
    if (t.IsFaulted)
        _logger.LogError(t.Exception, "Task em segundo plano falhou");
}, TaskContinuationOptions.OnlyOnFaulted);

Exceções Síncronas em Métodos Async

Uma exceção lançada antes do primeiro await em um método async ainda é capturada na Task retornada — ela não escapa de forma síncrona:

public async Task DoWorkAsync(string input)
{
    // Esta ArgumentNullException é capturada na Task, NÃO lançada sincronamente.
    // O chamador deve fazer await na task para observá-la.
    if (input is null) throw new ArgumentNullException(nameof(input));
 
    await Task.Delay(100);
}
 
// O chamador vê a exceção apenas ao fazer await:
try
{
    await DoWorkAsync(null); // Exceção surge aqui
}
catch (ArgumentNullException ex)
{
    Console.WriteLine(ex.Message);
}
💡

Para validação de argumentos que você quer executar imediatamente (antes de qualquer trabalho async), divida o método em um wrapper público síncrono que valida e uma implementação async privada. Este é o padrão usado por muitos métodos da BCL.

// Ponto de entrada público — síncrono, lança imediatamente em 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);
    // ... trabalho real
}

O Problema de Exceções em async void

Métodos async void são o padrão mais perigoso em async C#. Exceções lançadas dentro deles são elevadas diretamente no SynchronizationContext que estava ativo quando o método começou — elas não podem ser capturadas por um try/catch ao redor:

// PERIGOSO: A exceção não pode ser capturada pelos chamadores
private async void OnButtonClick(object sender, EventArgs e)
{
    await Task.Delay(100);
    throw new InvalidOperationException("Isso vai crashar o processo");
}
 
// Este bloco catch NÃO FAZ NADA para exceções de async void:
try
{
    OnButtonClick(this, EventArgs.Empty); // Retorna imediatamente (void)
}
catch (InvalidOperationException)
{
    // Nunca alcançado. A exceção foi elevada no SynchronizationContext.
}

Em um app WinForms ou WPF a exceção cai na thread de UI — a menos que algo como Application.ThreadException (WinForms) ou DispatcherUnhandledException (WPF) a intercepte, a aplicação morre. No ASP.NET Core não há SynchronizationContext, então a exceção é relançada no thread pool e derruba o processo como uma exceção não tratada comum.

Você verá a afirmação — uma versão anterior deste artigo também a fazia — de que essa rota chama Environment.FailFast. Não chama, e a diferença é testável: Environment.FailFast pula os handlers de AppDomain.UnhandledException, então fiz o sample relançar a si mesmo como processo filho, deixar uma exceção de async void escapar sem SynchronizationContext, e observar de fora. O handler AppDomain.UnhandledException do filho dispara, com IsTerminating=true, antes de o processo morrer com o banner padrão Unhandled exception do runtime e o código de saída 0xE0434352. Essa é a rota normal de exceção não tratada — o que também significa que um handler global ainda tem uma última chance de logar antes que um bug de async void mate seu serviço.

O Único async void Aceitável

Handlers de eventos são o único caso de uso legítimo, e mesmo assim você deve envolver o corpo em try/catch:

// Aceitável: handler de evento, mas proteja todo o corpo
private async void OnButtonClick(object sender, EventArgs e)
{
    try
    {
        await LoadDataAsync();
        UpdateUI();
    }
    catch (Exception ex)
    {
        // Tratar graciosamente — não podemos deixar isso escapar
        MessageBox.Show($"Erro: {ex.Message}");
    }
}
⚠️

Nunca use async void fora de handlers de eventos. Se um método precisa retornar void (ex.: uma implementação de interface), use async Task no lugar. Se a assinatura da interface é fixa, envolva a chamada async e trate exceções inline.

Convertendo async void para async Task

// Interface que você não pode mudar
public interface IProcessor
{
    void Process(string data);
}
 
// Implementação que precisa de trabalho async
public class DataProcessor : IProcessor
{
    // Padrão: fire-and-forget mas trata exceções inline
    public void Process(string data)
    {
        // NÃO torne este método async void.
        // Em vez disso, inicie a task e adicione tratamento de erros.
        _ = ProcessInternalAsync(data).ContinueWith(
            t => _logger.LogError(t.Exception, "Processamento falhou para {Data}", data),
            TaskContinuationOptions.OnlyOnFaulted
        );
    }
 
    private async Task ProcessInternalAsync(string data)
    {
        await Task.Delay(50);
        // trabalho async real
    }
}

AggregateException e Task.WhenAll

Task.WhenAll aguarda todas as tasks completarem independentemente de falhas — o sample verifica que uma task saudável roda até completar enquanto duas irmãs falham. Quando as tasks falham, a propriedade Exception da task combinada é um AggregateException contendo cada falha. Mas o await o desempacota: apenas a primeira exceção é relançada. As demais não são destruídas — continuam na task combinada — mas se você nunca guardou uma referência a ela, não tem como alcançá-las.

var tasks = new[]
{
    Task.FromException(new ArgumentException("Erro A")),
    Task.FromException(new InvalidOperationException("Erro B")),
    Task.FromException(new TimeoutException("Erro C")),
};
 
try
{
    await Task.WhenAll(tasks); // Apenas ArgumentException ("Erro A") é relançada!
}
catch (Exception ex)
{
    // ex é ArgumentException — Erro B e Erro C ficam inalcançáveis
    // porque nunca guardamos uma referência à task combinada
    Console.WriteLine(ex.Message); // "Erro A"
}

Capturando Todas as Exceções do Task.WhenAll

A solução é manter uma referência à task combinada antes de fazer await, depois inspecionar sua propriedade Exception:

public async Task ProcessAllAsync(IEnumerable<string> items)
{
    var tasks = items.Select(ProcessItemAsync).ToList();
 
    // Guardar a task agregada antes de fazer await
    var allTasks = Task.WhenAll(tasks);
 
    try
    {
        await allTasks;
    }
    catch
    {
        // allTasks.Exception é o AggregateException completo com TODAS as exceções internas
        if (allTasks.Exception is not null)
        {
            foreach (var inner in allTasks.Exception.InnerExceptions)
            {
                _logger.LogError(inner, "Task falhou: {Message}", inner.Message);
            }
        }
 
        // Relançar ou tratar conforme necessário
        throw;
    }
}

Achatar AggregateExceptions Aninhadas

AggregateExceptions podem se aninhar — um AggregateException cujas exceções internas são elas próprias AggregateExceptions. .Flatten() colapsa a hierarquia em um único nível. Mas seja preciso sobre quando o aninhamento realmente acontece, porque eu esperava a resposta errada: aninhar um Task.WhenAll dentro de outro não aninha os agregados. O sample faz await de um WhenAll de um WhenAll, e a task externa expõe as três exceções folha em uma lista plana.

Onde você obtém aninhamento genuíno é nos padrões antigos da TPL, como tasks filhas anexadas:

// Tasks filhas anexadas são a fonte clássica de agregados genuinamente aninhados
var parent = Task.Factory.StartNew(() =>
{
    Task.Factory.StartNew(
        () => throw new InvalidOperationException("da filha anexada"),
        TaskCreationOptions.AttachedToParent);
});
 
try
{
    parent.Wait();
}
catch (AggregateException ex)
{
    // ex.InnerExceptions[0] é OUTRO AggregateException — não o erro real.
    // Flatten() colapsa a hierarquia para uma lista plana de InnerExceptions:
    foreach (var inner in ex.Flatten().InnerExceptions)
    {
        Console.WriteLine($"{inner.GetType().Name}: {inner.Message}"); // InvalidOperationException
    }
}

Se seu código compõe tasks com WhenAll e await, raramente você precisa de Flatten() — mas chamá-lo antes de iterar não custa nada e torna o loop correto para as duas formas.

Coletando Resultados E Erros do Task.WhenAll

Às vezes você quer todos os resultados bem-sucedidos E todos os erros, não apenas o primeiro erro:

public async Task<(List<T> Results, List<Exception> Errors)> WhenAllSafeAsync<T>(
    IEnumerable<Task<T>> tasks)
{
    // Envolver cada task para que nunca falhe — captura sucesso ou falha
    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);
}

Comportamento de Exceções em Task.WhenAll vs Task.WhenAny

Task.WhenAny retorna assim que qualquer task é concluída — e "concluída" inclui falhada e cancelada, não apenas bem-sucedida. A task retornada é a própria task concluída, não um novo wrapper, e await Task.WhenAny(...) em si nunca lança. O sample torna a armadilha explícita colocando uma task pendente para competir com uma já falhada — a falhada vence:

var pending = Task.Delay(5_000).ContinueWith(_ => "resultado lento");
var alreadyFaulted = Task.FromException<string>(new InvalidOperationException("falha rápida"));
 
// winner É alreadyFaulted — WhenAny significa "primeira concluída", e uma falha
// conclui imediatamente. Note que este await NÃO lançou.
var winner = await Task.WhenAny(pending, alreadyFaulted);
 
// A exceção aparece apenas quando você faz await no próprio vencedor
try
{
    var result = await winner;
    Console.WriteLine(result);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Vencedor falhou: {ex.Message}");
}
// Nota: as outras tasks ainda estão executando! Suas exceções ficam não observadas
// a menos que você as trate explicitamente.
⚠️

Com Task.WhenAny, as tasks que NÃO venceram continuam executando. Se falharem depois, essas exceções ficam não observadas. Sempre adicione tratamento de erros às tasks que não venceram se você se importa com seus resultados.

Padrão: WhenAny com Limpeza

public async Task<string> RaceWithFallbackAsync(CancellationToken ct)
{
    var primary = FetchFromPrimaryAsync(ct);
    var secondary = FetchFromSecondaryAsync(ct);
 
    var first = await Task.WhenAny(primary, secondary);
 
    // Observar a outra task para prevenir avisos de exceção não observada
    _ = first == primary
        ? secondary.ContinueWith(t => { /* ignorar ou registrar */ }, ct)
        : primary.ContinueWith(t => { /* ignorar ou registrar */ }, ct);
 
    // Lançará se first falhou
    return await first;
}

Tratando Exceções de Tasks Paralelas Sem Perder Resultados

Um requisito comum: executar N tasks em paralelo, coletar todos os resultados e reportar todos os erros 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, "Falhou ao buscar {TaskId}", failure.TaskId);

ExceptionDispatchInfo — Relançando com Stack Trace Original

A instrução throw; preserva o stack trace ao relançar. Mas às vezes você precisa capturar uma exceção em um lugar e relançá-la em outro — para isso use ExceptionDispatchInfo:

using System.Runtime.ExceptionServices;
 
public class ExceptionRelay
{
    private ExceptionDispatchInfo? _captured;
 
    public void CaptureException(Action work)
    {
        try
        {
            work();
        }
        catch (Exception ex)
        {
            // Captura a exceção E seu stack trace completo neste ponto
            _captured = ExceptionDispatchInfo.Capture(ex);
        }
    }
 
    public void Rethrow()
    {
        // Relança com o stack trace ORIGINAL preservado: o trace mantém os frames
        // de onde a exceção foi lançada pela primeira vez, com este ponto de
        // relançamento acrescentado depois deles.
        _captured?.Throw();
    }
}

ExceptionDispatchInfo em 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; // Sucesso
        }
        catch (Exception ex) when (IsTransient(ex))
        {
            // Capture preserva o stack trace de cada tentativa
            lastException = ExceptionDispatchInfo.Capture(ex);
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
        }
    }
 
    // Todas as tentativas esgotadas — relança com stack trace original
    lastException!.Throw();
}

Filtros de Exceção com when

Filtros de exceção permitem capturar condicionalmente sem desfazer o stack, o que preserva mais informações de depuração (o stack ainda está intacto quando o filtro é executado):

public async Task ExecuteWithFilterAsync()
{
    try
    {
        await RiskyOperationAsync();
    }
    // Captura apenas erros HTTP transitórios — outros propagam normalmente
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
    {
        await HandleServiceUnavailableAsync();
    }
    // Captura apenas uma mensagem específica — útil durante depuração
    catch (InvalidOperationException ex) when (ex.Message.Contains("timeout"))
    {
        await HandleTimeoutAsync();
    }
    // Captura tudo MAS registra para diagnóstico sem capturar permanentemente
    catch (Exception ex) when (LogAndRethrow(ex))
    {
        // Este bloco não é alcançável se LogAndRethrow sempre retorna false
    }
}
 
// Padrão útil: log-and-rethrow via filtro de exceção
// O filtro executa ANTES de o stack ser desfeito, dando um trace completo
private bool LogAndRethrow(Exception ex)
{
    _logger.LogError(ex, "Exceção em ExecuteWithFilterAsync");
    return false; // Retornar false significa que a exceção NÃO é capturada
}
💡

Filtros when executam antes de o stack ser desfeito. Se seu filtro sempre retorna false, a exceção se propaga com seu stack completo intacto — isso é melhor que catch + throw para logging diagnóstico puro.

Combinando when com Filtros de Tipo

catch (SqlException ex) when (ex.Number == 1205) // Vítima de deadlock
{
    await RetryAfterDeadlockAsync();
}
 
catch (OperationCanceledException ex) when (ex.CancellationToken == _shutdownToken)
{
    // Captura apenas cancelamentos do NOSSO token, não de outros tokens
    _logger.LogInformation("Encerrando graciosamente");
}

Cancelamento vs Estado de Task com Falha

O cancelamento tem status especial no modelo de tasks do .NET. Um OperationCanceledException lançado de um método async transiciona a task para o estado Canceled (não Faulted), e await o relança como OperationCanceledException. (Para como os tokens são cancelados em primeiro lugar, veja CancellationToken em C# — Padrões Práticos.)

A regra exata me surpreendeu quando a verifiquei. Um método async que lança OperationCanceledException termina Canceled mesmo que o token anexado à exceção nunca tenha sido cancelado — a máquina de estados async trata como caso especial o tipo da exceção, não o estado do token. Um delegate síncrono em Task.Run tem o padrão oposto: lançar um OperationCanceledException deixa essa task Faulted, a menos que o token da exceção seja o mesmo que você passou ao Task.Run e esteja realmente cancelado. As quatro combinações estão verificadas no 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á preenchido — você pode verificar qual token cancelou
        Console.WriteLine($"Cancelado pelo token: {ex.CancellationToken == cts.Token}");
    }
}

Distinguindo Cancelamento de Outras Exceções

public async Task<Result> ProcessWithCancellationAsync(
    string input, 
    CancellationToken ct)
{
    try
    {
        return await DoProcessAsync(input, ct);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Esperado — o chamador solicitou cancelamento. Não é um erro.
        _logger.LogDebug("Processamento cancelado para input {Input}", input);
        return Result.Cancelled;
    }
    catch (OperationCanceledException ex)
    {
        // Um token DIFERENTE foi cancelado — ISSO É um erro inesperado
        _logger.LogWarning(ex, "Cancelamento inesperado");
        throw;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Erro inesperado processando {Input}", input);
        throw;
    }
}
💡

Sempre verifique ct.IsCancellationRequested no seu bloco catch de OperationCanceledException. Isso distingue "o chamador nos pediu para parar" (normal) de "alguma dependência expirou" (inesperado), o que pode ser um bug.

TaskCanceledException vs OperationCanceledException

TaskCanceledException herda de OperationCanceledException. HttpClient lança TaskCanceledException tanto para timeouts quanto para cancelamento explícito. Desde o .NET 5, a expiração do HttpClient.Timeout carrega adicionalmente uma TimeoutException interna — o sample de CancellationToken verifica isso contra um servidor local deliberadamente travado. Para suas próprias fontes de timeout, distinga pelo 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 — ou nosso timeout de 30s foi ativado
        throw new TimeoutException("Requisição expirou", ex);
    }
    // Token interno do HttpClient — normalmente não deveria chegar aqui
    throw;
}

Handler Global de Exceções de Tasks Não Observadas

Quando uma task com falha é coletada pelo GC sem que sua exceção tenha sido observada, TaskScheduler.UnobservedTaskException é disparado. Este é seu handler de último recurso para exceções de tasks fire-and-forget:

// Program.cs — registrar no startup
TaskScheduler.UnobservedTaskException += (sender, args) =>
{
    // args.Exception é o AggregateException envolvendo as exceções não observadas
    foreach (var ex in args.Exception.InnerExceptions)
    {
        _logger.LogCritical(ex, "Exceção de task não observada");
    }
 
    // Chame SetObserved() para evitar que a exceção seja relançada
    // pelo thread do finalizador (o que crasharia o processo em versões antigas do .NET)
    args.SetObserved();
};
⚠️

No .NET 4.0, exceções de tasks não observadas crashavam o processo. A partir do .NET 4.5, são silenciadas por padrão — mas você ainda deve tratá-las para capturar bugs. O timing do thread do finalizador é não determinístico, então este handler dispara imprevisívelmente durante ciclos do GC, não imediatamente quando a exceção ocorre.

AppDomain.UnhandledException — A Rede de Segurança Final

Para exceções verdadeiramente não tratadas (não em tasks), use AppDomain.CurrentDomain.UnhandledException. Isso dispara depois que o processo já está condenado — você pode registrar mas não pode prevenir o encerramento:

AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
    var ex = args.ExceptionObject as Exception;
    // args.IsTerminating é true quando o runtime está prestes a abortar
    _logger.LogCritical(ex, "Exceção fatal não tratada. IsTerminating={IsTerminating}", 
        args.IsTerminating);
 
    // Descarregar logs sincronamente — o processo está encerrando
    Log.CloseAndFlush();
};

Program.cs: Registrando Todos os Handlers Globais

var builder = WebApplication.CreateBuilder(args);
// ... registro de serviços
 
var app = builder.Build();
 
// Handlers globais — registrar antes de app.Run()
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
 
app.Run();
 
static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    var logger = /* resolver do DI ou usar logger estático */;
    logger.LogCritical(e.ExceptionObject as Exception, "Exceção não tratada");
}
 
static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
    var logger = /* resolver do DI ou usar logger estático */;
    logger.LogError(e.Exception, "Exceção de task não observada");
    e.SetObserved();
}

Middleware de Exceções no ASP.NET Core

A arquitetura baseada em pipeline do ASP.NET Core é ideal para tratamento centralizado de exceções. Middleware personalizado dá a você controle 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)
        {
            // Cliente desconectou — não é um erro, sem necessidade de resposta
            _logger.LogDebug("Requisição cancelada pelo cliente: {Path}", context.Request.Path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Exceção não tratada para {Method} {Path}",
                context.Request.Method,
                context.Request.Path);
 
            await WriteErrorResponseAsync(context, ex);
        }
    }
 
    private static async Task WriteErrorResponseAsync(HttpContext context, Exception ex)
    {
        // Não sobrescrever uma resposta que já começou a ser transmitida
        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 e UseExceptionHandler

// Program.cs
app.UseMiddleware<ExceptionHandlingMiddleware>();
 
// Ou use o 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 = "Ocorreu um erro inesperado",
            traceId = context.TraceIdentifier
        });
    });
});

Handler de Exceções com IExceptionHandler em Minimal APIs

O .NET 8 introduziu IExceptionHandler para tratamento estruturado de exceções que se integra com o contêiner 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, "Exceção ocorreu: {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 = exceção tratada, false = passar para o próximo handler
        return true;
    }
}
 
// Registro em Program.cs:
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();
 
app.UseExceptionHandler();

Padrões Práticos — Tudo Junto

Chamada HTTP Resiliente com Tratamento Completo de Exceções

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)
            {
                // Chamador cancelou — parar de tentar imediatamente
                _logger.LogDebug("Requisição cancelada: {Endpoint}", endpoint);
                throw;
            }
            catch (HttpRequestException ex) when (IsRetryable(ex))
            {
                _logger.LogWarning(ex, "Tentativa {Attempt}/{Max} falhou para {Endpoint}",
                    attempt, MaxRetries, endpoint);
 
                // Preservar stack trace para o relançamento final
                lastCapture = ExceptionDispatchInfo.Capture(ex);
 
                if (attempt < MaxRetries)
                    await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct);
            }
        }
 
        // Todas as tentativas esgotadas — relançar com stack trace original
        lastCapture!.Throw();
        return default; // Inatingível, mas satisfaz o compilador
    }
 
    private static bool IsRetryable(HttpRequestException ex) =>
        ex.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests
        || ex.StatusCode is null; // Falhas em nível de rede
}

Processamento em Lote com Isolamento de Erros

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 cancelamento
        }
        catch (Exception ex)
        {
            // Isolar a falha — outros itens continuam sendo processados
            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
);

Resumo

CenárioPadrão
Exceção async normaltry/catch ao redor de await — funciona como esperado
Exceção em async voidEnvolve com try/catch dentro do handler; evite async void completamente
Todas as exceções do Task.WhenAllMantém referência à task combinada; inspeciona .Exception.InnerExceptions
AggregateException aninhadaChama .Flatten() antes de iterar
Preservar stack trace ao relançarUse throw; ou ExceptionDispatchInfo.Capture().Throw()
Catch condicionalFiltro de exceção: catch (Ex e) when (condição)
Cancelamento vs falhacatch (OperationCanceledException) when (ct.IsCancellationRequested)
Rede de segurança fire-and-forgetTaskScheduler.UnobservedTaskException
Rede de segurança em nível de processoAppDomain.CurrentDomain.UnhandledException
Tratamento centralizado no ASP.NET CoreMiddleware personalizado ou IExceptionHandler (.NET 8+)

Cada afirmação de comportamento acima é verificada por samples/async-exception-handling-csharp — clone-o e execute dotnet run se quiser ver alguma delas falhar em um runtime futuro.

Dois temas vizinhos valem a leitura em seguida. CancellationToken em C# — Padrões Práticos cobre o lado produtor da distinção Canceled-vs-Faulted — tokens vinculados, timeouts, e saber de quem é o cancelamento que você capturou. E quando uma exceção nunca aparece porque o código está travado em vez de falhado, isso costuma ser um deadlock de sync-over-async, que exige ferramentas completamente diferentes.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados