C# の HttpClient のタイムアウトと CancellationToken
タイムアウトとキャンセルの違い、リクエストごとのタイムアウトのヘルパー、ResponseHeadersRead の抜け穴、遅い API に対するテスト。
例を API に対して最後に実行した日:
HTTP の呼び出しにはすべて時間制限が必要で、制限に達したときの処理も、正常系と同じくらいテストが必要です。テストするには、 好きなときに遅くなるサーバーが要ります。https://api.testingapis.com へのどのリクエストにも ?delay=N を付ければ、レスポンスは送られる前に N 秒(最大 10 秒)待ちます。無料でキーも不要です。このガイドでは、タイムアウトと キャンセルの種類ごとに HttpClient が何を投げるか、その見分け方、ストリーミングのコードがはまる HttpClient.Timeout の抜け穴、そして xUnit のテストを紹介します。すべて .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=2何が投げられ、どう見分けるか
HttpClient には Timeout プロパティ(変更しなければ 100 秒)があり、どのメソッドも CancellationToken を受け取ります。リクエストが途中で止まる 3 つの経路はどれも TaskCanceledException を投げます。自分で印を付けるのは HttpClient.Timeout だけで、内部例外が TimeoutException になります。
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 つの値です。呼び出しによって必要な時間が 違うなら、タイムアウトを呼び出し元のトークンにつなげます。例外フィルターがどのトークンが発火したかを確かめ、呼び出し元の ものでなければタイムアウトだと判断します。
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 report抜け穴:HttpClient.Timeout はヘッダーで止まる
ストリーミングや大きなダウンロードで使う HttpCompletionOption.ResponseHeadersRead を指定すると、GetAsync はヘッダーが届いた時点で戻ります。HttpClient.Timeout はそこで数えるのをやめます。その後ボディが 少しずつしか届かなくても、読み取りを止めるものは何もありません。/api/stream/dripサインインして表示 はヘッダーをすぐに送り、ボディをゆっくり送ります。
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 timeout2 秒のタイムアウトでは 6 秒かかる読み取りを止められませんでした。読み取りにもトークンを渡せば止まります。
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 秒前後」ということだからです。
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がカバーしないので、ストリーミングの読み取りにはトークンを渡す。- タイムアウトしたら、終わらないスピナーではなく、エラーとリトライの手段をユーザーに見せる。
- タイムアウト後のリトライには独自の上限を設け、遅いサービスにさらに負荷をかけない。