C# の HttpClient のタイムアウトと CancellationToken

タイムアウトとキャンセルの違い、リクエストごとのタイムアウトのヘルパー、ResponseHeadersRead の抜け穴、遅い API に対するテスト。

例を API に対して最後に実行した日:

HTTP の呼び出しにはすべて時間制限が必要で、制限に達したときの処理も、正常系と同じくらいテストが必要です。テストするには、 好きなときに遅くなるサーバーが要ります。https://api.testingapis.com へのどのリクエストにも ?delay=N を付ければ、レスポンスは送られる前に N 秒(最大 10 秒)待ちます。無料でキーも不要です。このガイドでは、タイムアウトと キャンセルの種類ごとに HttpClient が何を投げるか、その見分け方、ストリーミングのコードがはまる HttpClient.Timeout の抜け穴、そして xUnit のテストを紹介します。すべて .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

何が投げられ、どう見分けるか

HttpClient には Timeout プロパティ(変更しなければ 100 秒)があり、どのメソッドも CancellationToken を受け取ります。リクエストが途中で止まる 3 つの経路はどれも TaskCanceledException を投げます。自分で印を付けるのは HttpClient.Timeout だけで、内部例外が TimeoutException になります。

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); }
}
出力
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
リクエストを止めたもの例外見分け方
HttpClient.Timeout が切れたTaskCanceledException内部例外が TimeoutException
自分のタイムアウト用トークンが発火したTaskCanceledException自分の CancellationTokenSource の IsCancellationRequested が true
呼び出し元がキャンセルしたTaskCanceledException呼び出し元のトークンの IsCancellationRequested が true

タイムアウトは報告するかリトライすべき失敗ですが、呼び出し元のキャンセル(ユーザーがページを離れた、ASP.NET のリクエストが中断された)はたいてい何もする必要がありません。両方をエラーとして記録すると、ログがノイズだらけになります。

両者を区別するリクエストごとのタイムアウト

HttpClient.Timeout は、そのクライアントのすべてのリクエストに共通の 1 つの値です。呼び出しによって必要な時間が 違うなら、タイムアウトを呼び出し元のトークンにつなげます。例外フィルターがどのトークンが発火したかを確かめ、呼び出し元の ものでなければタイムアウトだと判断します。

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

抜け穴:HttpClient.Timeout はヘッダーで止まる

ストリーミングや大きなダウンロードで使う HttpCompletionOption.ResponseHeadersRead を指定すると、GetAsync はヘッダーが届いた時点で戻ります。HttpClient.Timeout はそこで数えるのをやめます。その後ボディが 少しずつしか届かなくても、読み取りを止めるものは何もありません。/api/stream/dripサインインして表示 はヘッダーをすぐに送り、ボディをゆっくり送ります。

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");
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

2 秒のタイムアウトでは 6 秒かかる読み取りを止められませんでした。読み取りにもトークンを渡せば止まります。

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
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

タイムアウトの xUnit テスト

クライアントが時間どおりにあきらめること、単に遅いだけのレスポンスはちゃんと待つこと、指示されたら遅いボディを止める ことを確かめます。.NET 10 上の xUnit 2.9 で通ります。最初のテストの時間の幅はわざと広くしています。CI のマシンやネットワークにはばらつきがあり、大事なのは「5 秒ではなく 2 秒前後」ということだからです。

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));
    }
}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

ほかの遅いエンドポイント

?delay は 10 秒までです。もっと長い停止、ランダムな遅延、遅いボディが必要なら、サインインしたメンバーは障害用の エンドポイントを使えます。/api/chaos/timeout?seconds=30サインインして表示 は最大 60 秒待ち、/api/chaos/random-latencyサインインして表示 は待ち時間を変え、/api/stream/dripサインインして表示(上で使ったもの)はボディが届く遅さを調整します。そのほかは好きなときにエラーを返す APIを参照してください。

クライアントで確認すること

  • すべての呼び出しに時間制限があり、ユーザーが待っている場面では既定の 100 秒より短い。
  • タイムアウトと呼び出し元のキャンセルを区別し、失敗として記録するのはタイムアウトだけ。
  • HttpClient.Timeout がカバーしないので、ストリーミングの読み取りにはトークンを渡す。
  • タイムアウトしたら、終わらないスピナーではなく、エラーとリトライの手段をユーザーに見せる。
  • タイムアウト後のリトライには独自の上限を設け、遅いサービスにさらに負荷をかけない。