Many good answers here, but I still would like to post my rant as I've just come across the same problem and conducted some research. Or skip to the TLDR version below.
The problem
Awaiting the task
returned by Task.WhenAll
only throws the first exception of the AggregateException
stored in task.Exception
, even when multiple tasks have faulted.
The current docs for Task.WhenAll
say:
If any of the supplied tasks completes in a faulted state, the
returned task will also complete in a Faulted state, where its
exceptions will contain the aggregation of the set of unwrapped
exceptions from each of the supplied tasks.
Which is correct, but it doesn't says anything about the aforementioned "unwrapping" behavior of when the returned task is awaited.
I suppose, the docs don't mention it because that behavior is not specific to Task.WhenAll
.
It is simply that Task.Exception
is of type AggregateException
and for await
continuations it always gets unwrapped as its first inner exception, by design. This is great for most cases, because usually Task.Exception
consists of only one inner exception. But consider this code:
Task WhenAllWrong()
{
var tcs = new TaskCompletionSource<DBNull>();
tcs.TrySetException(new Exception[]
{
new InvalidOperationException(),
new DivideByZeroException()
});
return tcs.Task;
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// task.Exception is an AggregateException with 2 inner exception
Assert.IsTrue(task.Exception.InnerExceptions.Count == 2);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(task.Exception.InnerExceptions[1], typeof(DivideByZeroException));
// However, the exception that we caught here is
// the first exception from the above InnerExceptions list:
Assert.IsInstanceOfType(exception, typeof(InvalidOperationException));
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
}
Here, an instance of AggregateException
gets unwrapped to its first inner exception InvalidOperationException
in exactly the same way as we might have had it with Task.WhenAll
. We could have failed to observe DivideByZeroException
if we did not go through task.Exception.InnerExceptions
directly.
Microsoft's Stephen Toub explains the reason behind this behavior in the related GitHub issue:
The point I was trying to make is that it was discussed in depth,
years ago, when these were originally added. We originally did what
you're suggesting, with the Task returned from WhenAll containing a
single AggregateException that contained all the exceptions, i.e.
task.Exception would return an AggregateException wrapper which
contained another AggregateException which then contained the actual
exceptions; then when it was awaited, the inner AggregateException
would be propagated. The strong feedback we received that caused us to
change the design was that a) the vast majority of such cases had
fairly homogenous exceptions, such that propagating all in an
aggregate wasn't that important, b) propagating the aggregate then
broke expectations around catches for the specific exception types,
and c) for cases where someone did want the aggregate, they could do
so explicitly with the two lines like I wrote. We also had extensive
discussions about what the behavior of await sould be with regards to
tasks containing multiple exceptions, and this is where we landed.
One other important thing to note, this unwrapping behavior is shallow. I.e., it will only unwrap the first exception from AggregateException.InnerExceptions
and leave it there, even if it happens to be an instance of another AggregateException
. This may add yet another layer of confusion. For example, let's change WhenAllWrong
like this:
async Task WhenAllWrong()
{
await Task.FromException(new AggregateException(
new InvalidOperationException(),
new DivideByZeroException()));
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// now, task.Exception is an AggregateException with 1 inner exception,
// which is itself an instance of AggregateException
Assert.IsTrue(task.Exception.InnerExceptions.Count == 1);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(AggregateException));
// And now the exception that we caught here is that inner AggregateException,
// which is also the same object we have thrown from WhenAllWrong:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
A solution (TLDR)
So, back to await Task.WhenAll(...)
, what I personally wanted is to be able to:
- Get a single exception if only one has been thrown;
- Get an
AggregateException
if more than one exception has been thrown collectively by one or more tasks;
- Avoid having to save the
Task
only for checking its Task.Exception
;
- Propagate the cancellation status properly (
Task.IsCanceled
), as something like this would not do that: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
I've put together the following extension for that:
public static class TaskExt
{
/// <summary>
/// A workaround for getting all of AggregateException.InnerExceptions with try/await/catch
/// </summary>
public static Task WithAggregatedExceptions(this Task @this)
{
// using AggregateException.Flatten as a bonus
return @this.ContinueWith(
continuationFunction: anteTask =>
anteTask.IsFaulted &&
anteTask.Exception is AggregateException ex &&
(ex.InnerExceptions.Count > 1 || ex.InnerException is AggregateException) ?
Task.FromException(ex.Flatten()) : anteTask,
cancellationToken: CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
scheduler: TaskScheduler.Default).Unwrap();
}
}
Now, the following works the way I want it:
try
{
await Task.WhenAll(
Task.FromException(new InvalidOperationException()),
Task.FromException(new DivideByZeroException()))
.WithAggregatedExceptions();
}
catch (OperationCanceledException)
{
Trace.WriteLine("Canceled");
}
catch (AggregateException exception)
{
Trace.WriteLine("2 or more exceptions");
// Now the exception that we caught here is an AggregateException,
// with two inner exceptions:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
catch (Exception exception)
{
Trace.WriteLine($"Just a single exception: ${exception.Message}");
}
AggregateException
. If you usedTask.Wait
instead ofawait
in your example, you'd catchAggregateException
– LatticedTask.WhenAll
, and I fell into the same trap. So I've tried going into deep details about this behavior. – TernateTask.Wait
is blocking,await
is not. – Kinematograph