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.
$ 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/api/Products/1?delay=2What 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:
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); }
}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 request | Exception | How to recognise it |
|---|---|---|
| HttpClient.Timeout ran out | TaskCanceledException | The inner exception is a TimeoutException |
| Your own timeout token fired | TaskCanceledException | Your CancellationTokenSource has IsCancellationRequested set |
| The caller cancelled | TaskCanceledException | The 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.
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.");
}
}
}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");
}Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to reportThe 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:
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");Headers after 0.2s
Body read after 6.1s, past the 2 s timeoutThe two-second timeout didn't stop a six-second read. Pass a token to the read as well, and it does:
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 sxUnit 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".
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));
}
}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.Timeoutdoesn'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.