Timeout dan cancellation token HttpClient di C#

Timeout versus pembatalan, helper timeout per request, celah ResponseHeadersRead, dan tes terhadap API yang lambat.

Contoh terakhir dijalankan terhadap API pada

Setiap panggilan HTTP butuh batas waktu, dan kode yang menangani saat batas itu tercapai butuh pengujian sebanyak jalur normalnya. Untuk mengujinya, Anda butuh server yang bisa lambat sesuai permintaan. Tambahkan ?delay=N ke request apa pun di https://api.testingapis.com dan response akan menunggu N detik (hingga 10) sebelum dikirim. Gratis dan tanpa key. Panduan ini menunjukkan apa yang dilempar HttpClient untuk setiap jenis timeout dan pembatalan, cara membedakannya, celah di HttpClient.Timeout yang menjebak kode streaming, dan tes xUnit. Semuanya dijalankan di .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

Apa yang dilempar, dan cara membedakannya

HttpClient punya properti Timeout, 100 detik kecuali Anda mengubahnya, dan setiap metode juga menerima CancellationToken. Ketiga cara request bisa berhenti lebih awal sama-sama melempar TaskCanceledException. Hanya HttpClient.Timeout yang memberi tanda, dengan TimeoutException sebagai 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
Yang menghentikan requestExceptionCara mengenalinya
HttpClient.Timeout habisTaskCanceledExceptionInner exception-nya adalah TimeoutException
Token timeout Anda sendiri terpicuTaskCanceledExceptionCancellationTokenSource Anda punya IsCancellationRequested bernilai true
Pemanggil membatalkanTaskCanceledExceptionToken pemanggil punya IsCancellationRequested bernilai true

Timeout adalah kegagalan yang perlu dilaporkan atau diulang; pemanggil yang membatalkan (pengguna meninggalkan halaman, request ASP.NET dibatalkan) biasanya tidak butuh apa-apa. Mencatat keduanya sebagai error memenuhi log Anda dengan kebisingan.

Timeout per request yang memisahkan keduanya

HttpClient.Timeout adalah satu nilai untuk semua request di klien itu. Saat sebagian panggilan butuh waktu lebih singkat dari yang lain, hubungkan timeout ke token pemanggil. Filter exception memeriksa token siapa yang terpicu: jika bukan milik pemanggil, berarti itu 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

Celahnya: HttpClient.Timeout berhenti di header

Dengan HttpCompletionOption.ResponseHeadersRead, yang dipakai untuk streaming dan unduhan besar, GetAsync kembali begitu header tiba. HttpClient.Timeout berhenti menghitung di situ. Jika body kemudian datang menetes, tidak ada yang menghentikan pembacaannya. /api/stream/dripmasuk untuk melihat mengirim header-nya seketika dan body-nya perlahan:

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");
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis
Output
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

Timeout dua detik tidak menghentikan pembacaan enam detik. Berikan juga token ke pembacaannya, dan barulah berhenti:

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
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Tes xUnit untuk timeout

Tes ini memeriksa bahwa klien menyerah tepat waktu, tetap menunggu response yang hanya lambat, dan menghentikan body yang lambat saat diminta. Lulus dengan xUnit 2.9 di .NET 10. Rentang waktu di tes pertama sengaja dibuat lebar: mesin CI dan jaringan bervariasi, dan intinya adalah "sekitar dua detik, bukan lima".

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));
    }
}
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Endpoint lambat lainnya

?delay berhenti di 10 detik. Untuk macet yang lebih lama, latensi acak, atau body yang lambat, anggota yang sudah masuk bisa memakai endpoint kegagalan: /api/chaos/timeout?seconds=30masuk untuk melihat menunggu hingga 60 detik, /api/chaos/random-latencymasuk untuk melihat memvariasikan waktu tunggu, dan /api/stream/dripmasuk untuk melihat (dipakai di atas) mengatur seberapa lambat body tiba. Lihat error sesuai permintaan untuk lainnya.

Yang perlu diperiksa di klien Anda

  • Setiap panggilan punya batas waktu, lebih pendek dari 100 detik bawaan saat pengguna menunggu.
  • Timeout dan pembatalan oleh pemanggil dibedakan, dan hanya timeout yang dicatat sebagai kegagalan.
  • Pembacaan streaming diberi token, karena HttpClient.Timeout tidak mencakupnya.
  • Timeout menampilkan error dan cara mencoba lagi kepada pengguna, bukan spinner tanpa akhir.
  • Retry setelah timeout punya batasnya sendiri, agar layanan yang lambat tidak makin terbebani.