C# 中 HttpClient 的超时与取消令牌

超时与取消的区别、单请求超时辅助方法、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。请求提前停止的三种方式都会抛出 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 对该客户端的所有请求都是同一个值。当某些调用需要的时间比别的短时,就把一个超时和调用方的令牌关联起来。异常过滤器会检查是谁的令牌触发了:如果不是调用方的,那就是超时。

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

两秒的超时没能停止一次长达六秒的读取。给读取也传一个令牌,就能停止了:

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 机器和网络各不相同,要点是“接近两秒,而不是五秒”。

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登录后查看(上面用过)控制响应体到达的速度。其余内容见按需返回错误。

在客户端中要检查什么

  • 每个调用都有时间限制,在用户等待的场景中要短于默认的 100 秒。
  • 区分超时和调用方取消,只有超时才记录为故障。
  • 流式读取要传入令牌,因为 HttpClient.Timeout 覆盖不到它们。
  • 超时后要向用户显示错误和重试的方式,而不是无休止的转圈。
  • 超时后的重试要有自己的上限,以免让本就缓慢的服务雪上加霜。