TestingAPIs is still being built. If something breaks, email support@testingapis.com.

HttpClient timeouts and cancellation tokens in C#

Timeouts versus cancellations, a per-request timeout helper, the ResponseHeadersRead gap, and tests against a slow API.

Examples last run against the API on

Every HTTP call needs a time limit, and the code that handles hitting it needs testing as much as the happy path. To test it you need a server that is slow on demand. Add ?delay=N to any request on https://api.testingapis.comand the response waits N seconds (up to 10) before it's sent. It's free and needs no key. This guide shows what HttpClient throws for each kind of timeout and cancellation, how to tell them apart, a gap in HttpClient.Timeout that catches streaming code, and xUnit tests. Everything was run on .NET 10.

Terminal
$ time curl -s -o /dev/null -w "%{http_code}\n" "https://api.testingapis.com/api/Products/1?delay=5"
200

real    0m5.223s
user    0m0.015s
sys     0m0.000s
GET/api/Products/1?delay=2

What gets thrown, and how to tell them apart

HttpClient has a Timeout property, 100 seconds unless you change it, and every method also accepts a CancellationToken. All three ways a request can stop early throw a TaskCanceledException. Only HttpClient.Timeout marks itself, with a TimeoutException as the inner exception:

Program.cs
using System.Diagnostics;

const string Base = "https://api.testingapis.com";
var watch = Stopwatch.StartNew();
void Log(string what, Exception ex) => Console.WriteLine(
    $"{what,-22} {watch.Elapsed.TotalSeconds:F1}s  {ex.GetType().Name}, inner {ex.InnerException?.GetType().Name}");

Console.WriteLine($"Default timeout: {new HttpClient().Timeout}");

// 1. HttpClient.Timeout
using (var http = new HttpClient { BaseAddress = new Uri(Base), Timeout = TimeSpan.FromSeconds(2) })
{
    watch.Restart();
    try { await http.GetStringAsync("/api/Products/1?delay=5"); }
    catch (TaskCanceledException ex) { Log("HttpClient.Timeout", ex); Console.WriteLine($"  {ex.Message}"); }
}

using (var http = new HttpClient { BaseAddress = new Uri(Base) })
{
    // 2. A per-request timeout with a CancellationTokenSource
    using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
    watch.Restart();
    try { await http.GetStringAsync("/api/Products/1?delay=5", timeout.Token); }
    catch (TaskCanceledException ex) { Log("token timeout", ex); }

    // 3. The caller cancels first (a user leaves the page, a request is aborted)
    using var caller = new CancellationTokenSource(TimeSpan.FromSeconds(1));
    watch.Restart();
    try { await http.GetStringAsync("/api/Products/1?delay=5", caller.Token); }
    catch (TaskCanceledException ex) { Log("caller cancelled", ex); }
}
Output
Default timeout: 00:01:40
HttpClient.Timeout     2.0s  TaskCanceledException, inner TimeoutException
  The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing.
token timeout          2.0s  TaskCanceledException, inner TaskCanceledException
caller cancelled       1.0s  TaskCanceledException, inner TaskCanceledException
What stopped the requestExceptionHow to recognise it
HttpClient.Timeout ran outTaskCanceledExceptionThe inner exception is a TimeoutException
Your own timeout token firedTaskCanceledExceptionYour CancellationTokenSource has IsCancellationRequested set
The caller cancelledTaskCanceledExceptionThe caller's token has IsCancellationRequested set

A timeout is a failure to report or retry; a caller who cancelled (a user leaving the page, an aborted ASP.NET request) usually needs nothing at all. Logging both as errors fills your logs with noise.

A per-request timeout that keeps them apart

HttpClient.Timeoutis one value for every request on that client. When some calls need less time than others, link a timeout to the caller's token. The exception filter checks whose token fired: if it wasn't the caller's, it was the timeout.

Http.cs
public static class Http
{
    /// GET with a time limit. A timeout becomes a TimeoutException; the caller's own
    /// cancellation stays an OperationCanceledException, so the two can be handled differently.
    public static async Task<string> GetStringWithTimeoutAsync(
        HttpClient http, string url, TimeSpan timeout, CancellationToken cancellationToken)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(timeout);
        try
        {
            return await http.GetStringAsync(url, cts.Token);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            throw new TimeoutException($"No response from {url} within {timeout.TotalSeconds} s.");
        }
    }
}
Program.cs
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };

try
{
    await Http.GetStringWithTimeoutAsync(http, "/api/Products/1?delay=5", TimeSpan.FromSeconds(2), CancellationToken.None);
}
catch (TimeoutException ex)
{
    Console.WriteLine($"Timed out: {ex.Message}");
}

using var leaving = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
    await Http.GetStringWithTimeoutAsync(http, "/api/Products/1?delay=5", TimeSpan.FromSeconds(4), leaving.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelled by the caller: nothing to report");
}
Output
Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to report

The gap: HttpClient.Timeout stops at the headers

With HttpCompletionOption.ResponseHeadersRead, used for streaming and large downloads, GetAsync returns as soon as the headers arrive. HttpClient.Timeout stops counting there. If the body then trickles in, nothing stops the read. /api/stream/dripsign in to see sends its headers at once and its body slowly:

Program.cs
using System.Diagnostics;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.testingapis.com"),
    Timeout = TimeSpan.FromSeconds(2),
};
var watch = Stopwatch.StartNew();

// Headers arrive at once; the 6 bytes of body take 6 seconds.
using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
    HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"Headers after {watch.Elapsed.TotalSeconds:F1}s");

var body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Body read after {watch.Elapsed.TotalSeconds:F1}s, past the 2 s timeout");
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

The two-second timeout didn't stop a six-second read. Pass a token to the read as well, and it does:

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
    HttpCompletionOption.ResponseHeadersRead, cts.Token);
var body = await response.Content.ReadAsStringAsync(cts.Token); // throws after 2 s
This example uses endpoints for signed-in members.Sign inCreate a free account

xUnit tests for timeouts

These check that the client gives up on time, still waits for a response that's merely slow, and stops a slow body when told to. They pass with xUnit 2.9 on .NET 10. The time range in the first test is wide on purpose: CI machines and networks vary, and the point is "near two seconds, not five".

TimeoutTests.cs
using System.Diagnostics;
using Xunit;

public class TimeoutTests
{
    private static readonly Uri Api = new("https://api.testingapis.com");

    [Fact]
    public async Task Gives_up_after_the_configured_timeout()
    {
        using var http = new HttpClient { BaseAddress = Api, Timeout = TimeSpan.FromSeconds(2) };
        var watch = Stopwatch.StartNew();

        var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => http.GetStringAsync("/api/Products/1?delay=5"));

        Assert.IsType<TimeoutException>(ex.InnerException);
        Assert.InRange(watch.Elapsed.TotalSeconds, 1.5, 4.5); // gave up near 2 s, didn't wait 5
    }

    [Fact]
    public async Task Waits_for_a_slow_response_within_the_timeout()
    {
        using var http = new HttpClient { BaseAddress = Api, Timeout = TimeSpan.FromSeconds(10) };
        var json = await http.GetStringAsync("/api/Products/1?delay=2");
        Assert.Contains("\"id\"", json);
    }

    [Fact]
    public async Task Stops_reading_a_slow_body_when_cancelled()
    {
        using var http = new HttpClient { BaseAddress = Api };
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
        using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
            HttpCompletionOption.ResponseHeadersRead, cts.Token);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => response.Content.ReadAsStringAsync(cts.Token));
    }
}
This example uses endpoints for signed-in members.Sign inCreate a free account

Other slow endpoints

?delay stops at 10 seconds. For longer hangs, random latency or slow bodies, signed-in members can use the failure endpoints: /api/chaos/timeout?seconds=30sign in to see waits up to 60 seconds, /api/chaos/random-latencysign in to see varies the wait, and /api/stream/dripsign in to see (used above) controls how slowly the body arrives. See errors on demand for the rest.

What to check in your client

  • Every call has a time limit, shorter than the 100-second default where users are waiting.
  • Timeouts and caller cancellations are told apart, and only timeouts are logged as failures.
  • Streaming reads pass a token, since HttpClient.Timeout doesn't cover them.
  • A timeout shows the user an error and a way to retry, never an endless spinner.
  • Retries after a timeout have their own limit, so a slow service isn't hit harder.