Skip to main content
ANVISoftware Solutions
Lesson 55 of 62Advanced18 min

Exceptions in Async Code

By the end of this lesson

Handle failures in asynchronous work, including several concurrent failures.

A synchronous method that fails throws where it fails, and the exception travels up the call stack until something catches it. Asynchronous methods cannot do that. By the time an HTTP call fails, the method that started it has already returned a task and the stack that called it may be long gone.

So the failure is stored instead. The task is marked as faulted and it holds the exception. Nothing is thrown at the moment of failure.

The exception comes out when you await the task. At that point await inspects the task, finds the stored exception, and throws it into your method at the line of the await. This is why try and catch around an await behave the way you expect, even though the failure happened somewhere else entirely, possibly on another thread.

A failure delivered at the await, not where it happened
C#
public async Task<string> ConfirmAsync(int orderId)
{
    try
    {
        Order order = await _orders.GetAsync(orderId);

        await _payments.ChargeAsync(order.Total, order.PaymentMethodId);
        await _orders.MarkPaidAsync(order.Id);

        return $"Order {order.Reference} paid.";
    }
    catch (PaymentDeclinedException ex)
    {
        // Expected outcome of a payment attempt. Handle it, do not treat it as a crash.
        _logger.LogInformation(ex, "Payment declined for order {OrderId}.", orderId);

        return "Payment was declined.";
    }
    catch (HttpRequestException ex)
    {
        // The provider was unreachable. Different problem, different response.
        _logger.LogError(ex, "Payment provider unreachable for order {OrderId}.", orderId);

        throw;
    }
}
  • The try block contains three awaits. A failure in any of them is thrown at its own await line, so one try covers all three.
  • ChargeAsync may have failed on a thread pool thread several milliseconds ago. The exception was parked on the task and handed to your catch when you awaited. The stack trace still shows where it was originally thrown.
  • Two catch blocks distinguish two genuinely different situations. A declined card is a business outcome the caller can act on. An unreachable provider is an infrastructure fault.
  • throw on its own rethrows the exception with its original stack trace intact. Writing throw ex instead resets the trace to this line and loses where the problem actually started.
  • MarkPaidAsync never runs if ChargeAsync throws, exactly as in synchronous code. Control flow is unchanged by the awaits.

When several tasks fail at once

Task.WhenAll hides all but the first failure
C#
public async Task NotifyAllAsync(IReadOnlyList<Order> orders)
{
    List<Task> sends = orders
        .Select(order => _email.SendConfirmationAsync(order))
        .ToList();

    Task all = Task.WhenAll(sends);

    try
    {
        await all;
    }
    catch (Exception)
    {
        // await threw ONE exception. There may be more.
        // all.Exception is an AggregateException holding every failure.
        IReadOnlyList<Exception> failures =
            all.Exception?.InnerExceptions ?? new List<Exception>();

        foreach (Exception failure in failures)
        {
            _logger.LogError(failure, "Confirmation email failed.");
        }

        // Alternatively, inspect each task individually to know which order failed.
        for (int i = 0; i < sends.Count; i++)
        {
            if (sends[i].IsFaulted)
            {
                _logger.LogError(
                    sends[i].Exception,
                    "Confirmation failed for order {Reference}.",
                    orders[i].Reference);
            }
        }

        throw;
    }
}
  • Task.WhenAll waits for every task. If four of them fail, all four failures are recorded, but await can only throw one exception — so it throws the first.
  • The task variable is kept in a local, which is the part people miss. Writing await Task.WhenAll(sends) directly gives you no way to reach the combined result afterwards, and the other failures are unreachable.
  • all.Exception is an AggregateException. Its InnerExceptions collection holds every failure from every task. That is where the ones await did not show you are waiting.
  • The second loop pairs each task with the order it belongs to, using the index. AggregateException tells you what went wrong; only the task list tells you which item it went wrong for, which is usually what you need to retry or report.
  • Exception is caught rather than a specific type only because the aim here is to observe all failures before deciding. The bare throw preserves the original exception and stack trace for the caller.
  • A subtlety worth knowing: if one of these tasks is cancelled rather than faulted, await Task.WhenAll throws OperationCanceledException, and a cancelled task contributes nothing to Exception. Check IsCanceled as well when both are possible.

Summary

  • A failing asynchronous method stores its exception on the task rather than throwing immediately
  • await pulls that exception out and throws it at the await line, so ordinary try and catch work
  • Task.WhenAll records every failure but await surfaces only the first — read the combined task's AggregateException for the rest
  • Keep the individual tasks if you need to know which item failed, not just that something did
  • An unawaited task carries its failure to nowhere: fire-and-forget loses errors silently

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

A nightly job uploads 200 invoices to a partner API using Task.WhenAll. The log shows one entry: a timeout for invoice 4471. The partner reports that 30 invoices are missing.

The code catches Exception around the await and logs ex.Message. Explain why 29 failures left no trace, and what you would change.

Show solution

await threw the first exception that Task.WhenAll had recorded, and the catch logged that one. The other 29 were stored on the combined task's AggregateException, which the code never looked at. Nothing was swallowed by the runtime — the code never asked for them.

The first change is to hold the combined task in a variable and iterate its Exception.InnerExceptions, so every failure is logged.

The more useful change is to keep the tasks alongside their invoices and check each one, because InnerExceptions tells you that 30 timeouts occurred but not which invoices they belonged to. Without that mapping you cannot retry the right 30.

There is a design question hiding behind the logging bug. Thirty timeouts out of 200 concurrent uploads is a sign the partner API is being overwhelmed, which the next lesson deals with. Fixing the visibility would let you see the real problem, which is why silent failure is more expensive than it looks.

Try it yourself

Try it yourself

Write a method that sends a notification for each order in a list, keeps going when an individual send fails, and returns how many succeeded along with the orders that did not.

Do not let one bad email address stop the other 99 notifications.

Show solution

The catch goes inside the per-order work, not around the batch. That is what makes continuing possible: each send has its own try, so a failure ends one item rather than the whole operation.

Returning the failed orders rather than just a count is the part worth arguing for. A count tells you something went wrong; a list lets a caller retry, alert, or put the items on a queue. Handling a failure means leaving someone able to act.

Each failure is still logged with its exception, so the cause is recoverable from the logs even if the caller ignores the returned list. Continuing past an error and hiding it are different decisions and this does only the first.

Exception is caught broadly here on purpose, because the point is that no single address can stop the batch. That is defensible at a boundary like this one, where the alternative is losing 99 notifications, and it is only acceptable because the exception is recorded rather than discarded.

C#
public record NotifyOutcome(int Sent, IReadOnlyList<Order> Failed);

public async Task<NotifyOutcome> NotifyAsync(
    IReadOnlyList<Order> orders,
    CancellationToken cancellationToken)
{
    int sent = 0;
    List<Order> failed = new();

    foreach (Order order in orders)
    {
        try
        {
            await _email.SendConfirmationAsync(order, cancellationToken);
            sent++;
        }
        catch (OperationCanceledException)
        {
            // A stop request is not a per-order failure. Let it end the batch.
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Notification failed for order {Reference}.", order.Reference);
            failed.Add(order);
        }
    }

    return new NotifyOutcome(sent, failed);
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Six tasks are passed to Task.WhenAll and three of them fail. You await the result inside a try block. How many exceptions does the catch block see?
Why does catch (PaymentDeclinedException) fail to match when the call was made with ChargeAsync(...).Result?

Saved in this browser only.