C# 中 HttpClient 的超时与取消令牌
超时与取消的区别、单请求超时辅助方法、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。请求提前停止的三种方式都会抛出 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 对该客户端的所有请求都是同一个值。当某些调用需要的时间比别的短时,就把一个超时和调用方的令牌关联起来。异常过滤器会检查是谁的令牌触发了:如果不是调用方的,那就是超时。
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 timeout两秒的超时没能停止一次长达六秒的读取。给读取也传一个令牌,就能停止了:
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 机器和网络各不相同,要点是“接近两秒,而不是五秒”。
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登录后查看(上面用过)控制响应体到达的速度。其余内容见按需返回错误。
在客户端中要检查什么
- 每个调用都有时间限制,在用户等待的场景中要短于默认的 100 秒。
- 区分超时和调用方取消,只有超时才记录为故障。
- 流式读取要传入令牌,因为
HttpClient.Timeout覆盖不到它们。 - 超时后要向用户显示错误和重试的方式,而不是无休止的转圈。
- 超时后的重试要有自己的上限,以免让本就缓慢的服务雪上加霜。