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).
| Demo | Verificado |
|---|---|
| Relançamento no await | await lança o tipo de exceção original, não AggregateException; a task termina Faulted; task.Exception é o wrapper AggregateException |
| Throw antes do primeiro await | chamar 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 + SyncContext | um try/catch em volta da chamada async void não captura nada; um SynchronizationContext personalizado recebe a exceção via Post |
| async void sem SyncContext | o 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.WhenAll | await 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 aninhados | tasks filhas anexadas aninham AggregateExceptions e Flatten() as colapsa; WhenAll-de-WhenAll permanece plano |
Task.WhenAny | uma 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ção | um filtro when que retorna false observa a exceção sem capturá-la; a mesma instância continua se propagando |
| Canceled vs Faulted | um 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 traces | throw; e ExceptionDispatchInfo preservam o frame original; throw ex; o apaga |
| Exceções não observadas | TaskScheduler.UnobservedTaskException dispara durante a coleta de lixo, não no momento da falha |

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ário | Padrão |
|---|---|
| Exceção async normal | try/catch ao redor de await — funciona como esperado |
| Exceção em async void | Envolve com try/catch dentro do handler; evite async void completamente |
Todas as exceções do Task.WhenAll | Mantém referência à task combinada; inspeciona .Exception.InnerExceptions |
AggregateException aninhada | Chama .Flatten() antes de iterar |
| Preservar stack trace ao relançar | Use throw; ou ExceptionDispatchInfo.Capture().Throw() |
| Catch condicional | Filtro de exceção: catch (Ex e) when (condição) |
| Cancelamento vs falha | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| Rede de segurança fire-and-forget | TaskScheduler.UnobservedTaskException |
| Rede de segurança em nível de processo | AppDomain.CurrentDomain.UnhandledException |
| Tratamento centralizado no ASP.NET Core | Middleware 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.