HttpClient-Timeouts und Cancellation Tokens in C#
Timeouts gegenüber Abbrüchen, ein Timeout-Helfer pro Request, die Lücke bei ResponseHeadersRead und Tests gegen eine langsame API.
Beispiele zuletzt am gegen die API ausgeführt
Jeder HTTP-Aufruf braucht ein Zeitlimit, und der Code, der das Erreichen dieses Limits behandelt, braucht genauso viele Tests wie der Normalfall. Dafür brauchst du einen Server, der auf Wunsch langsam ist. Hänge ?delay=N an einen beliebigen Request auf https://api.testingapis.com, und die Antwort wartet N Sekunden (bis zu 10), bevor sie gesendet wird. Kostenlos und ohne Schlüssel. Dieser Leitfaden zeigt, was HttpClient bei jeder Art von Timeout und Abbruch wirft, wie du sie unterscheidest, eine Lücke in HttpClient.Timeout, in die Streaming-Code tappt, und xUnit-Tests. Alles lief auf .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=2Was geworfen wird und wie du es unterscheidest
HttpClient hat eine Eigenschaft Timeout, 100 Sekunden, sofern du sie nicht änderst, und jede Methode nimmt außerdem ein CancellationToken an. Alle drei Wege, auf denen ein Request vorzeitig endet, werfen eine TaskCanceledException. Nur HttpClient.Timeout kennzeichnet sich selbst, mit einer TimeoutException als innerer Ausnahme:
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| Was den Request gestoppt hat | Ausnahme | Woran du es erkennst |
|---|---|---|
| HttpClient.Timeout ist abgelaufen | TaskCanceledException | Die innere Ausnahme ist eine TimeoutException |
| Dein eigenes Timeout-Token hat ausgelöst | TaskCanceledException | Deine CancellationTokenSource hat IsCancellationRequested gesetzt |
| Der Aufrufer hat abgebrochen | TaskCanceledException | Das Token des Aufrufers hat IsCancellationRequested gesetzt |
Ein Timeout ist ein Fehler, den man meldet oder wiederholt; ein Aufrufer, der abgebrochen hat (ein Nutzer verlässt die Seite, ein ASP.NET-Request wird abgebrochen), braucht meist gar nichts. Beides als Fehler zu protokollieren, füllt deine Logs mit Rauschen.
Ein Timeout pro Request, das beides auseinanderhält
HttpClient.Timeout ist ein einziger Wert für alle Requests dieses Clients. Brauchen manche Aufrufe weniger Zeit als andere, verknüpfe ein Timeout mit dem Token des Aufrufers. Der Ausnahmefilter prüft, wessen Token ausgelöst hat: War es nicht das des Aufrufers, war es das 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 reportDie Lücke: HttpClient.Timeout endet bei den Headern
Mit HttpCompletionOption.ResponseHeadersRead, das für Streaming und große Downloads genutzt wird, kehrt GetAsync zurück, sobald die Header da sind. HttpClient.Timeout hört dort auf zu zählen. Tröpfelt der Body danach nur langsam herein, stoppt nichts das Lesen. /api/stream/dripanmelden zum Anzeigen schickt die Header sofort und den Body langsam:
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 timeoutDas Zwei-Sekunden-Timeout hat ein sechs Sekunden langes Lesen nicht gestoppt. Gib auch dem Lesen ein Token mit, dann klappt es:
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 für Timeouts
Sie prüfen, dass der Client rechtzeitig aufgibt, auf eine nur langsame Antwort trotzdem wartet und einen langsamen Body stoppt, wenn man es ihm sagt. Sie bestehen mit xUnit 2.9 auf .NET 10. Der Zeitbereich im ersten Test ist absichtlich breit: CI-Maschinen und Netze schwanken, und es geht um "ungefähr zwei Sekunden, nicht fünf".
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));
}
}Weitere langsame Endpunkte
?delay endet bei 10 Sekunden. Für längeres Hängen, zufällige Latenz oder langsame Bodies können angemeldete Mitglieder die Fehler-Endpunkte nutzen: /api/chaos/timeout?seconds=30anmelden zum Anzeigen wartet bis zu 60 Sekunden, /api/chaos/random-latencyanmelden zum Anzeigen variiert die Wartezeit, und /api/stream/dripanmelden zum Anzeigen (oben genutzt) steuert, wie langsam der Body ankommt. Den Rest findest du unter Fehler auf Wunsch.
Was du in deinem Client prüfen solltest
- Jeder Aufruf hat ein Zeitlimit, kürzer als die standardmäßigen 100 Sekunden, wo Nutzer warten.
- Timeouts und Abbrüche durch den Aufrufer werden unterschieden, und nur Timeouts als Fehler protokolliert.
- Streaming-Lesevorgänge bekommen ein Token, weil
HttpClient.Timeoutsie nicht abdeckt. - Ein Timeout zeigt dem Nutzer einen Fehler und eine Möglichkeit, es erneut zu versuchen, nie einen endlosen Spinner.
- Retries nach einem Timeout haben ein eigenes Limit, damit ein langsamer Dienst nicht noch stärker belastet wird.