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.

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

Was 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:

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); }
}
Ausgabe
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 hatAusnahmeWoran du es erkennst
HttpClient.Timeout ist abgelaufenTaskCanceledExceptionDie innere Ausnahme ist eine TimeoutException
Dein eigenes Timeout-Token hat ausgelöstTaskCanceledExceptionDeine CancellationTokenSource hat IsCancellationRequested gesetzt
Der Aufrufer hat abgebrochenTaskCanceledExceptionDas 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.

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");
}
Ausgabe
Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to report

Die 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:

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");
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Ausgabe
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

Das Zwei-Sekunden-Timeout hat ein sechs Sekunden langes Lesen nicht gestoppt. Gib auch dem Lesen ein Token mit, dann klappt es:

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
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

xUnit-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".

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));
    }
}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

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.Timeout sie 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.