Exceptions in C# Async: AggregateException and Errors in WhenAll
An exception in an async method goes into the task and is thrown with its original type at the await. Throwing parameter checks at the call, the difference between Wait, GetResult and await, reaching every error and partial results from WhenAll, an error order that differs between two overloads, AggregateException.Handle and unobserved task exceptions.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
The order screen asks for the price and the stock at the same time and waits for both with Task.WhenAll. Bad day: the price service crashes after 200 ms, the stock service times out after 100 ms. Both tasks end with an error. What does the catch block print? An exception carrying both errors, the price error, or the stock error?
Task<decimal> priceTask = GetPriceAsync();
Task<int> stockTask = GetStockAsync();
try
{
await Task.WhenAll(priceTask, stockTask);
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
static async Task<decimal> GetPriceAsync()
{
await Task.Delay(200);
throw new InvalidOperationException("Price service is down");
}
static async Task<int> GetStockAsync()
{
await Task.Delay(100);
throw new TimeoutException("Stock service timed out");
}
The Answer: await Throws a Single Error
TimeoutException: Stock service timed out
Two errors happened, and the catch saw one. Is the price error lost? No, it is sitting inside the task returned by Task.WhenAll; but await threw only one of them at you. This behavior is a deliberate design decision, and behind it is the way Task stores exceptions. We will also see shortly which error counts as "first"; here it was not the price, which comes first in the list, but the stock service that crashed first.
In this post we follow the path an exception takes in async code from start to finish: where it is caught, where it is stored, when it is thrown, and how you reach all of them when there is more than one error.
The Exception Waits Inside the Task
In the previous post we saw this line in the compiler-generated state machine: the catch block caught the exception and put it into the task with Builder.SetException(ex). An exception thrown in an async method does not go straight to the caller; it is recorded inside the task, and the task ends in the Faulted state. The exception is thrown again only when someone awaits that task:
Task saveTask = SaveOrderAsync();
Console.WriteLine($"Called, status: {saveTask.Status}");
try
{
await saveTask;
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Caught on await: {ex.Message}");
}
Console.WriteLine($"Status: {saveTask.Status}");
Console.WriteLine($"Stored: {saveTask.Exception?.GetType().Name}, inner count: {saveTask.Exception?.InnerExceptions.Count}");
static async Task SaveOrderAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Database is down");
}
Called, status: WaitingForActivation
Caught on await: Database is down
Status: Faulted
Stored: AggregateException, inner count: 1
Calling the method threw nothing. The error happened 100 ms later, was put into the task, and was thrown on the await saveTask line with its original type, as an InvalidOperationException. But when we look at the task's Exception property we see a different type: AggregateException. A task always stores exceptions inside an AggregateException, because a task can end with more than one error. The task from WhenAll is the most typical example.
So why did await throw what was inside rather than the AggregateException? Because the point of async/await is to read like synchronous code. A synchronous SaveOrder() call would throw an InvalidOperationException; await SaveOrderAsync() does the same. That is what lets you write catch (InvalidOperationException). On top of that, when await rethrows the exception it preserves the original stack trace; in your error logs you see the line where the error really happened.
The Error Is Thrown at the await, Not at the Call
The exception going into the task applies to the lines before the await too. Even a parameter check, on the method's first line, is not thrown at the call:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Task<decimal> lazy = GetPriceLazyAsync("");
Console.WriteLine($"Lazy call returned, status: {lazy.Status}");
try
{
Task<decimal> eager = GetPriceEager("");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Eager call threw: {ex.Message}");
}
try
{
await lazy;
}
catch (ArgumentException ex)
{
Console.WriteLine($"Lazy threw on await: {ex.Message}");
}
static async Task<decimal> GetPriceLazyAsync(string isbn)
{
if (string.IsNullOrWhiteSpace(isbn))
{
throw new ArgumentException("ISBN is required", nameof(isbn)); // goes into the task
}
await Task.Delay(100);
return 12.99m;
}
static Task<decimal> GetPriceEager(string isbn)
{
if (string.IsNullOrWhiteSpace(isbn))
{
throw new ArgumentException("ISBN is required", nameof(isbn)); // thrown straight at the caller
}
return FetchAsync(isbn);
static async Task<decimal> FetchAsync(string isbn)
{
await Task.Delay(100);
return 12.99m;
}
}
Lazy call returned, status: Faulted
Eager call threw: ISBN is required (Parameter 'isbn')
Lazy threw on await: ISBN is required (Parameter 'isbn')
The GetPriceLazyAsync("") call returned without a problem; what we got back was a task already in the Faulted state. The error stayed silent until the line where we awaited the task. If the code that starts the task and the code that awaits it are far apart, for example if the task is put in a list and awaited in bulk later, finding the source of this error gets harder.
GetPriceEager uses a two-part structure instead: the outer method is not async; it checks the parameter and throws the error directly, and the actual async work lives in the inner local function. This pattern, which you also see often in .NET's own libraries, lets you catch programmer errors (an empty ISBN, a null argument) at the moment of the call. Real runtime errors (the service crashed, a timeout) still come through the task.
Where AggregateException Comes From
As long as you use await, you rarely run into AggregateException. It is usually seen by code that reaches the result some way other than await. To see the difference, let's also use the blocking Wait(), just for this purpose:
Task failing = SaveOrderAsync();
try
{
failing.Wait(); // only to see the difference; do not write this in real code
}
catch (Exception ex)
{
Console.WriteLine($"Wait(): {ex.GetType().Name} -> {ex.InnerException?.GetType().Name}");
}
try
{
failing.GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.WriteLine($"GetAwaiter().GetResult(): {ex.GetType().Name}");
}
try
{
await failing;
}
catch (Exception ex)
{
Console.WriteLine($"await: {ex.GetType().Name}");
}
static async Task SaveOrderAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Database is down");
}
Wait(): AggregateException -> InvalidOperationException
GetAwaiter().GetResult(): InvalidOperationException
await: InvalidOperationException
Wait() and Result come from TPL's pre-await world and throw the stored AggregateException as it is; you look for the real error in InnerException. GetAwaiter().GetResult() is a method of the awaiter that await uses, so it behaves like await and throws the inner exception. That is why in older code you may see GetAwaiter().GetResult() instead of .Result. But both block the thread; the deadlock risk is the same. The right fix is await.
In modern code you also see AggregateException in two other places: in the Task.Exception property and in the errors thrown by Parallel.ForEach and PLINQ, which we will see later in the series. In both, several pieces of work run in parallel and all of their errors are collected into a single exception.
Seeing Every Error from WhenAll
Back to the question at the top. Task.WhenAll collects every error and puts them in the AggregateException of the task it returns. await throws only the first of them. If you want to see them all, keep WhenAll's task in a variable and look at its Exception property inside the catch:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string[] isbns = { "978-0441172719", "978-0553293357", "978-0553283686" };
Task<decimal>[] priceTasks = isbns.Select(GetPriceAsync).ToArray();
Task<decimal[]> all = Task.WhenAll(priceTasks);
try
{
decimal[] prices = await all;
Console.WriteLine($"Sum: {prices.Sum()}");
}
catch (Exception ex)
{
Console.WriteLine($"await threw: {ex.Message}");
foreach (Exception inner in all.Exception!.InnerExceptions)
{
Console.WriteLine($" - {inner.GetType().Name}: {inner.Message}");
}
for (int i = 0; i < priceTasks.Length; i++)
{
if (priceTasks[i].IsCompletedSuccessfully)
{
Console.WriteLine($" {isbns[i]} still has a price: {await priceTasks[i]}");
}
}
}
static async Task<decimal> GetPriceAsync(string isbn)
{
switch (isbn)
{
case "978-0553293357":
await Task.Delay(300);
throw new InvalidOperationException($"No price for {isbn}");
case "978-0553283686":
await Task.Delay(100);
throw new TimeoutException($"Price service timed out for {isbn}");
default:
await Task.Delay(200);
return 12.99m;
}
}
await threw: No price for 978-0553293357
- InvalidOperationException: No price for 978-0553293357
- TimeoutException: Price service timed out for 978-0553283686
978-0441172719 still has a price: 12.99
We learn three things. First, WhenAll does not stop waiting for the others when it sees an error; it waits until every task has finished and then collects all of their errors. Second, the order in InnerExceptions. In this example the order is the order of the tasks in the list: the price error that fell at 300 ms came before the timeout that fell at 100 ms, and await threw that one. In the example at the top it was the other way round; the stock service that crashed first won. The difference is that the two examples use different overloads of WhenAll. Here the generic version taking a Task<decimal>[] runs; at the top it was the non-generic version taking two tasks of different types. When I passed the same three tasks to the two overloads separately, on the .NET 8 and .NET 10 runtimes the generic version gave the list order and the non-generic version gave the order in which the errors happened. In other words, this order is an implementation detail; do not write code that depends on which error comes first. Third, alongside the tasks that failed, the tasks that finished successfully do not lose their results. If a partial result is good enough, you look at each task separately.
Sorting Out Errors: Handle
Sometimes you expect some errors and want to log them and move on, while throwing the rest further up. AggregateException.Handle exists exactly for that:
Task timeout = FailAsync(new TimeoutException("Stock service timed out"));
Task broken = FailAsync(new InvalidOperationException("Invoice template is missing"));
Task all = Task.WhenAll(timeout, broken);
try
{
try
{
await all;
}
catch
{
all.Exception!.Handle(ex =>
{
if (ex is TimeoutException)
{
Console.WriteLine($"Logged and ignored: {ex.Message}");
return true; // handled
}
return false; // not handled, will be rethrown
});
}
}
catch (AggregateException rest)
{
Console.WriteLine($"Still failing: {string.Join(", ", rest.InnerExceptions.Select(e => e.Message))}");
}
static async Task FailAsync(Exception error)
{
await Task.Delay(100);
throw error;
}
Logged and ignored: Stock service timed out
Still failing: Invoice template is missing
Handle calls the function you pass for each inner exception. The ones that return true count as handled. If at least one returns false, Handle throws a new AggregateException containing only those. If you run into nested AggregateExceptions, for example because one task waited on another with Wait(), Flatten() brings them all down to a single level.
Errors Nobody Looks At
What happens to the exception inside a task you never await? Remember from the earlier posts: if you ignore the CS4014 warning and throw the task away, the error disappears silently. Or rather, almost silently: when the garbage collector collects that task, if its error was never observed, the runtime raises the TaskScheduler.UnobservedTaskException event:
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.WriteLine($"Unobserved: {e.Exception.InnerException?.Message}");
e.SetObserved();
};
StartAndForget();
await Task.Delay(200);
GC.Collect(); // we force the task to be collected so we can see the event
GC.WaitForPendingFinalizers();
Console.WriteLine("Program still running");
static void StartAndForget()
{
_ = SaveOrderAsync();
}
static async Task SaveOrderAsync()
{
await Task.Delay(50);
throw new InvalidOperationException("Database is down");
}
Unobserved: Database is down
Program still running
The event is raised not when the error happens but at some unpredictable moment when the GC collects the task; in the example we ran the GC by hand so we could see it. In .NET Framework 4.0, an unobserved task exception crashed the process; since 4.5, and in .NET Core, the default behavior is to keep the process alive. So even if you do not subscribe to the event, the program keeps running, but nobody sees the error. This event is a safety net, not an error handling strategy: you use it to write to the log, but the real fix is for someone to await every task.
Common Pitfalls
Writing catch (AggregateException) around an await. Since await throws the inner exception, this catch does not run at all in most cases. Catch the actual type the awaited work throws.
Settling for a single error from WhenAll. If three of ten services crashed in a batch job and you only log one of them, you will hear about the other two problems only after that error is fixed. Keep WhenAll's task and record every entry in Exception.InnerExceptions.
Assuming a parameter check in an async method throws right away. Every exception inside an async method, even on the first line, goes into the task. If you want to see the error at the moment of the call, move the validation into an outer method that is not async.
Treating cancellation like an error. When you await a canceled task, an OperationCanceledException (usually its subtype TaskCanceledException) is thrown, and the task's status is Canceled, not Faulted. Writing work that stopped halfway because the user said "never mind" into the error log fills the log with noise. Handle it separately with catch (OperationCanceledException); the details are in the cancellation post.
A Small Experiment
★☆☆ In the example at the top, swap the two Task.Delay durations: price 100 ms, stock 200 ms. Which message does the catch print this time? Compare the result with what we observed about the InnerExceptions order.
★★☆ Turn GetPriceEager into a fully async method and run the same program. Why did the "Eager call threw" line disappear? On which line does the error show up?
★★★ Add a third task to the Handle example: FailAsync(new TimeoutException("Price service timed out")). Then remove the outermost catch and see what the program prints. With which type does the unhandled error end the program?
We have seen that in async code an error is put into the task, comes back in its original form through await, and how WhenAll carries more than one error. In the next post we look at Task.WhenAll and Task.WhenAny from the side of concurrency rather than errors: running several jobs at once, taking the first one to finish and putting a time limit on a job.