You await Task.WhenAll over three calls and two of them fail. What does your catch block see?
One exception. WhenAll's task faults with an AggregateException holding every failure, but await rethrows only the first inner exception, so recovering the rest means keeping the WhenAll task in a variable and reading its Exception property.
What the interviewer is scoring
- Does the candidate state that await rethrows the first inner exception while the task itself holds all of them
- Whether they can recover the remaining failures, and recognise that it requires holding the WhenAll task in a variable
- That the faulted and cancelled outcomes of WhenAll are distinguished rather than cancellation being treated as a fault
- Whether the losing tasks of WhenAny are identified as unobserved, and what becomes of their exceptions
- Does the answer address what partial failure leaves behind, not only how to read the exception
Answer
WhenAll aggregates, await unwraps
Task.WhenAll returns a single task that completes when all the supplied tasks have completed. If any of them faulted, the returned task faults, and its Exception property is an AggregateException whose InnerExceptions collection contains every failure. That is the complete picture of what happened, and it is available.
await then throws away most of it. When you await a faulted task, the compiler-generated code does not hand you the AggregateException; it takes the first inner exception and rethrows it with its original stack trace preserved. That design is deliberate and mostly a kindness — it means catch (HttpRequestException) works the way you would want on a single awaited call, instead of forcing you to unwrap an aggregate every time. The cost is that on WhenAll the same mechanism silently discards the other failures.
var a = CallA(); // faults with TimeoutException after 100 ms
var b = CallB(); // faults with HttpRequestException after 50 ms
var c = CallC(); // succeeds
var all = Task.WhenAll(a, b, c); // keep the task; you will need it in the catch
try
{
await all;
}
catch (Exception ex)
{
// ex is ONE exception, the first inner one. Not an AggregateException.
// all.Exception.InnerExceptions has both failures.
foreach (var inner in all.Exception!.InnerExceptions) Log(inner);
}
Two details in that trace are worth being precise about. First, "the first inner exception" is first in the order WhenAll assembled them, which follows the order of the tasks you passed in rather than the order in which they failed — so with b failing at 50 ms and a at 100 ms, the exception you catch is still a's TimeoutException. Second, all.Exception is only reachable because the task is in a variable. Written as await Task.WhenAll(a, b, c) there is nothing left to interrogate, and the second failure is unrecoverable from inside the catch.
Why a lost exception here is not also a crash
Reading each faulted task's exception to build the aggregate counts as observing it, so the individual tasks do not later raise unobserved-exception events. That is what makes this quiet. You lose the information, not the process.
The contrast is Task.WhenAny, which completes as soon as the first task completes and does nothing at all with the others. Those losers keep running, and if one of them faults later with nobody awaiting it, its exception is unobserved. Since .NET Framework 4.5, an unobserved task exception does not terminate the process: the runtime raises TaskScheduler.UnobservedTaskException when the task is finalised and then swallows it. So WhenAny used for a timeout race, which is its most common use, routinely creates failures that are invisible unless you subscribe to that event or attach a continuation to the losers. On older behaviour this would have crashed the process and you would have known.
Cancellation is a third outcome, not a fault
WhenAll resolves to one of three states and the precedence matters. If any task faulted, the returned task is faulted. If none faulted but at least one was cancelled, the returned task is cancelled, and awaiting it throws OperationCanceledException — a catch (Exception) will see that too, and code that logs everything it catches as an error will report a client hanging up as a service failure.
The practical consequence is that a broad catch around a WhenAll conflates three genuinely different situations: real upstream failures, a cancellation you asked for, and a cancellation you did not. Handle OperationCanceledException separately and check the token you passed, so that "the caller went away" is logged as information and "the upstream timed out" is logged as an error.
The failures you did not catch are still side effects
The exception plumbing is the visible half of this question and the smaller half. Three parallel calls with two failures means one of them succeeded, and if these are writes rather than reads, your system is now in a state no single request intended: an order created without a payment authorisation, a user provisioned in one downstream and not the other.
WhenAll gives you no transaction and no rollback, so the compensation is yours to design. The options are the usual ones and they should be named explicitly: make each operation idempotent so the whole set can be retried safely, or record intent durably first and let a worker drive each part to completion, or accept the partial state and reconcile it asynchronously. What is not acceptable is catching the first exception, returning a 500, and leaving the one write that succeeded unmentioned, because that write is now permanent and nothing in your logs says so.
The habits that follow
Keep the WhenAll task in a variable whenever more than one failure is worth knowing about, and log the aggregate rather than the rethrown exception. Give every parallel call a timeout and a token so WhenAll cannot wait on something that never completes — one task that hangs forever makes the whole group hang forever, and no amount of exception handling saves you from that.
Where you genuinely want every outcome rather than the first error, stop using exceptions for control flow across the set: have each operation return a result value, await WhenAll over tasks that do not throw, and inspect the results. That inverts the default, so partial failure becomes data you must handle rather than an exception you might accidentally flatten to one line.
WhenAll collects every failure and await hands you exactly one of them, so the exception you catch is a sample rather than a summary — and the successful task in a failed batch is a state change you now own.
Likely follow-ups
- Four of five parallel writes succeeded and one failed. What do you do about the four?
- What is AggregateException.Flatten for, and what produces nesting deep enough to need it?
- How does WhenAll behave when one task never completes at all, and how would you bound that?
- Where does an exception go from a task that was started with Task.Run and never awaited?
Related questions
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on async and tasks4 min
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on async4 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumAlso on async5 min
- Two users edit the same record and both save. What does EF Core do about it?mediumAlso on dotnet5 min
- Walk me through the phases of the Node event loop.mediumAlso on async4 min
- Write a helper that polls a condition until it is true or a timeout expires, then tell me what yours does when the condition throws.mediumAlso on async4 min
- Your .NET service keeps getting OOM-killed in its container while the GC reports a small heap. Where has the memory gone?hardAlso on dotnet6 min
- A read-only endpoint that returns fifty thousand rows is slow and memory-heavy in EF Core. What is the context doing?mediumAlso on dotnet4 min