用 C# 的 HttpClient 测试 HTTP 503 处理

HttpClient 如何处理 503、一个遵守 Retry-After 的 DelegatingHandler,以及针对线上 API 的 xUnit 测试。

示例最后一次针对 API 运行于

503 表示服务暂时不可用:过载、正在重启或在维护中。好的客户端会等待后重试;粗心的客户端则会崩溃、在紧密的循环里不停重试,或者显示一个空白页面。 要测试你的客户端属于哪一种,你需要一个按需返回 503 的服务器。https://api.testingapis.com 上的 GET /api/status/503 总是返回 503,并带有 Retry-After 响应头;?error=503 则能让任何真实端点以同样方式失败。两者都免费,不需要密钥。本指南介绍 HttpClient 如何处理 503、如何读取 Retry-After、一个小巧的重试处理器以及针对它的 xUnit 测试。所有示例都在 .NET 10 上运行过。

返回 503 的端点

请求返回什么用来测试
GET /api/status/503每次都返回带 Retry-After: 5 的 503读取 Retry-After;尝试 N 次后放弃
GET /api/Products/1?error=503带 X-Simulated: true、没有 Retry-After 的 503应用真实发出的调用失败;没有提示时的退避
GET /api/Todos/1?delay=2&error=5032 秒后返回 503加载状态和超时同时出现
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503登录后查看两次 503 后返回 200;X-Attempt 计数最终成功的重试
Terminal
$ curl -i https://api.testingapis.com/api/status/503
HTTP/1.1 503 Service Unavailable
content-type: application/json; charset=utf-8
retry-after: 5

{"status":503,"statusText":"Service Unavailable","category":"server-error","message":"This is a 503 response. Use /api/status/{code} to request any status."}
GET/api/status/503

HttpClient 如何处理 503

GetAsync 遇到错误状态码不会抛出异常。它只返回响应,检查它是你的责任。直接调用 ReadFromJsonAsync 的代码会把错误响应体当作数据去反序列化。EnsureSuccessStatusCode 和 GetFromJsonAsync 系列方法则会抛出异常:一个 HttpRequestException,其 StatusCode 属性(自 .NET 5 起)会告诉你那是 503。

Program.cs
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };

// A 503 is a normal response, not an exception.
using var response = await http.GetAsync("/api/status/503");
Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($"IsSuccessStatusCode: {response.IsSuccessStatusCode}");
Console.WriteLine($"Retry-After: {response.Headers.RetryAfter?.Delta?.TotalSeconds} seconds");

// EnsureSuccessStatusCode turns it into one, with the status attached.
try
{
    response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"HttpRequestException: StatusCode={(int?)ex.StatusCode}");
}

// GetFromJsonAsync throws before reading the body.
try
{
    await http.GetFromJsonAsync<JsonElement>("/api/Products/1?error=503");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"GetFromJsonAsync threw: {(int?)ex.StatusCode}");
}
输出
Status: 503 ServiceUnavailable
IsSuccessStatusCode: False
Retry-After: 5 seconds
HttpRequestException: StatusCode=503
GetFromJsonAsync threw: 503

Retry-After 已经替你解析好了:服务器发送秒数时用 response.Headers.RetryAfter.Delta,发送 HTTP 日期时用 .Date。模拟错误则不同。和许多真实故障一样,它没有 Retry-After,所以你的代码需要一个兜底值:

Terminal
$ curl -i "https://api.testingapis.com/api/Products/1?error=503"
HTTP/1.1 503 Service Unavailable
x-simulated: true

{"status":503,"error":"Service Unavailable","message":"Simulated error. Use ?error={code} to test different status codes.","simulated":true}

遵守 Retry-After 的重试处理器

DelegatingHandler 位于 HttpClient 的管道中,所以不用改动调用代码,每个请求都会应用同样的策略。这个处理器会重试 503 和 429,按照 Retry-After 的要求等待(但设有上限,以免服务器让你干等一个小时),没有时就退回到指数退避,并在固定次数后停止。

RetryHandler.cs
using System.Net;

/// Retries 503 and 429, waiting as long as Retry-After asks (capped), up to maxAttempts.
public sealed class RetryHandler(int maxAttempts, TimeSpan maxDelay) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        for (var attempt = 1; ; attempt++)
        {
            var response = await base.SendAsync(request, cancellationToken);
            var retryable = response.StatusCode is HttpStatusCode.ServiceUnavailable
                                                or HttpStatusCode.TooManyRequests;
            if (!retryable || attempt == maxAttempts)
            {
                return response;
            }

            // Retry-After is either a number of seconds or a date. Without it, back off 0.5 s, 1 s, 2 s...
            var wait = response.Headers.RetryAfter?.Delta
                ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow)
                ?? TimeSpan.FromMilliseconds(500 * Math.Pow(2, attempt - 1));
            if (wait > maxDelay) wait = maxDelay;
            if (wait < TimeSpan.Zero) wait = TimeSpan.Zero;

            Console.WriteLine($"  attempt {attempt}: {(int)response.StatusCode}, waiting {wait.TotalSeconds:F1}s");
            response.Dispose();
            await Task.Delay(wait, cancellationToken);
        }
    }
}
Program.cs
using System.Diagnostics;

using var http = new HttpClient(
    new RetryHandler(maxAttempts: 3, maxDelay: TimeSpan.FromSeconds(2)) { InnerHandler = new HttpClientHandler() })
{
    BaseAddress = new Uri("https://api.testingapis.com"),
};

var watch = Stopwatch.StartNew();
using var response = await http.GetAsync("/api/status/503");
Console.WriteLine($"After retries: {(int)response.StatusCode} in {watch.Elapsed.TotalSeconds:F1}s");

watch.Restart();
using var ok = await http.GetAsync("/api/Products/1");
Console.WriteLine($"Healthy endpoint: {(int)ok.StatusCode} in {watch.Elapsed.TotalSeconds:F1}s");

面对永远不会恢复的 /api/status/503,它尝试三次,因为服务器要求等五秒,所以每次都只等到两秒的上限,然后返回最后一个 503。健康的端点则直接通过:

输出
attempt 1: 503, waiting 2.0s
  attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0s

测试一个会恢复的服务

放弃只是行为的一半,另一半是在服务恢复时能够恢复过来。/api/chaos/retry/{key}登录后查看 对每个 key 先失败固定的次数再成功,它的 X-Attempt 响应头说明已经收到了多少个请求。它发送 Retry-After: 1,所以上面的处理器大约两秒后就能恢复。

Terminal
$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=503"
HTTP/1.1 503 Service Unavailable
retry-after: 1
x-attempt: 1

$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=503"
HTTP/1.1 503 Service Unavailable
retry-after: 1
x-attempt: 2

$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=503"
HTTP/1.1 200 OK
x-attempt: 3
这个示例使用的是面向已登录成员的端点。登录创建免费账户

处理器的 xUnit 测试

这些测试针对线上 API 运行,所以测试的是真实的网络行为:状态码、响应头和耗时。每次运行使用新的 key,可以防止重试计数器在多次运行之间串扰。三个测试在 .NET 10 上用 xUnit 2.9 全部通过。

RetryHandlerTests.cs
using System.Net;
using Xunit;

public class RetryHandlerTests
{
    private static HttpClient CreateClient() =>
        new(new RetryHandler(maxAttempts: 3, maxDelay: TimeSpan.FromSeconds(2)) { InnerHandler = new HttpClientHandler() })
        {
            BaseAddress = new Uri("https://api.testingapis.com"),
            Timeout = TimeSpan.FromSeconds(30),
        };

    [Fact]
    public async Task Gives_up_after_three_attempts_on_a_lasting_503()
    {
        using var http = CreateClient();
        using var response = await http.GetAsync("/api/status/503");
        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
    }

    [Fact]
    public async Task Recovers_when_the_service_comes_back()
    {
        using var http = CreateClient();
        var key = $"test-{Guid.NewGuid():N}"; // a fresh counter per run
        using var response = await http.GetAsync($"/api/chaos/retry/{key}?succeedAfter=2&failStatus=503");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("3", response.Headers.GetValues("X-Attempt").Single());
    }

    [Fact]
    public async Task Does_not_retry_a_400()
    {
        using var http = CreateClient();
        using var response = await http.GetAsync("/api/status/400");
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}
这个示例使用的是面向已登录成员的端点。登录创建免费账户

调用公共 API 的测试依赖于它在线,所以要把它们和单元测试分在不同的类别中。xUnit 集成测试指南讲了这一点,以及如何在 GitHub Actions 中运行它们。

在客户端中要检查什么

  • 503 被当作错误处理,没有任何代码把错误响应体当作数据读取。
  • 遵守 Retry-After,支持秒数和日期两种形式,并设有上限。
  • 没有 Retry-After 时,两次尝试之间的等待时间逐渐增加,而不是猛烈冲击服务器。
  • 重试达到固定次数后停止,最终的错误要传达给用户或写入日志。
  • 除 429 以外的 4xx 错误不重试;错误的请求重发还是错误的。
  • 只有在重复不会造成损害时才重试写操作。

更多故障模式,包括超时和被截断的响应,见一个按需返回错误的模拟 API。