Async .NET · Lesson 14

Synchronous guards and faulted tasks

Distinguish a rejected call from a task that represents a failed operation.

How it works

An API can reject invalid arguments synchronously before returning a task. Start is an ordinary method returning Task<int>, so its guard throws directly during the call. The first catch handles that rejection. No started asynchronous operation needs to be awaited in order to discover the negative count.

Fail is an async Task<int> method. Its exception becomes the failure of the task it returns, even though the awaited completed task does not cause a suspension here. The caller receives a faulted task, observes IsFaulted as true, and then awaits it to propagate and handle the InvalidOperationException.

This difference matters when designing a public asynchronous API. A small non-async wrapper can make usage errors immediate, while an inner async method reports operational failures through its completion. Callers should still await the returned task and not assume that a successfully returned task means the operation itself succeeded.

Do not infer exception timing from the location of the first await alone. The method's async state-machine contract affects how failures are delivered. A synchronous helper called before the API returns and an exception thrown inside an async Task body can reach the caller through different paths even when both execute quickly.

Practice by passing zero to Start and awaiting its result, then moving the guard into a separate async method. Compare whether the invalid call itself throws or the returned task is faulted. Keep the expected argument error distinct from the simulated operational failure in your output.

Finally, write a caller that handles both phases clearly without abandoning a task. Validation should use precise exception types and parameter names, while operational error handling should surround the await that observes completion. Avoid fire-and-forget calls used merely to escape error handling; they can leave failures detached from the workflow that owns them.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

try { Start(-1); }
catch (ArgumentOutOfRangeException) { Console.WriteLine("argument"); }
Task<int> failed = Fail();
Console.WriteLine(failed.IsFaulted);
try { await failed; }
catch (InvalidOperationException) { Console.WriteLine("operation"); }
static Task<int> Start(int count) {
    if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
    return Task.FromResult(count);
}
static async Task<int> Fail() {
    await Task.CompletedTask;
    throw new InvalidOperationException("simulated operation failure");
}

Expected output

argument
True
operation
Try this
A non-async wrapper can validate synchronously; an async Task method stores its failure in the task.