C# の HttpClient で HTTP 503 の処理をテストする

HttpClient が 503 をどう扱うか、Retry-After を守る DelegatingHandler、ライブの API に対する xUnit テスト。

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

503 は、サービスが一時的に利用できないことを意味します。過負荷、再起動中、メンテナンス中などです。良いクライアントは待って から再試行しますが、雑なクライアントはクラッシュしたり、間を置かずにリトライを繰り返したり、真っ白な画面を出したりします。 自分のクライアントがどちらかを確かめるには、頼めば 503 を返すサーバーが必要です。https://api.testingapis.com の GET /api/status/503 は、Retry-After ヘッダー付きで常に 503 を返し、?error=503 を付ければどの実在のエンドポイントも同じように失敗します。どちらも無料でキーも不要です。このガイドでは、HttpClient が 503 をどう扱うか、Retry-After の読み方、小さなリトライハンドラー、そのための xUnit テストを紹介します。例はすべて .NET 10 で実行しました。

503 を返すエンドポイント

リクエスト返ってくるものテストできること
GET /api/status/503毎回 Retry-After: 5 付きの 503Retry-After の読み取り、N 回でのあきらめ
GET /api/Products/1?error=503X-Simulated: true 付き、Retry-After なしの 503アプリが行う実際の呼び出しの失敗、手がかりのないバックオフ
GET /api/Todos/1?delay=2&error=5032 秒後に 503読み込み中の表示とタイムアウトの組み合わせ
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503サインインして表示503 が 2 回の後に 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 が求めるだけ待ち(ただしサーバーに 1 時間止められないよう上限付き)、なければ指数バックオフに切り替え、決まった回数で 止めます。

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 に対しては 3 回試します。サーバーは 5 秒を求めますが、毎回上限の 2 秒だけ待ち、最後の 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}サインインして表示 はキーごとに決まった回数だけ失敗してから成功し、X-Attempt ヘッダーで受け取ったリクエスト数を示します。Retry-After: 1 を送るので、上のハンドラーは約 2 秒で回復します。

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 に対して実行するので、ステータスコード、ヘッダー、タイミングといった実際の通信の振る舞いをテストできます。 実行ごとに新しいキーを使えば、リトライのカウンターが実行をまたいで漏れることはありません。3 つとも .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 を呼ぶテストは、その API が動いていることに依存するので、ユニットテストとは別のカテゴリに分けておきましょう。xUnit の統合テストのガイドで、その方法と GitHub Actions での実行方法を説明しています。

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

  • 503 はエラーとして扱う。エラーボディをデータとして読もうとしない。
  • Retry-After を、秒数と日付の両方の形式で、上限付きで守る。
  • Retry-After がなければ、サーバーを叩き続けずに試行の間隔を広げる。
  • リトライは決まった回数で止め、最後のエラーをユーザーかログに届ける。
  • 429 以外の 4xx はリトライしない。悪いリクエストは悪いまま。
  • 書き込みのリトライは、繰り返しても害がない場合だけ。

タイムアウトや途中で切れるレスポンスなど、ほかの失敗パターンは好きなときにエラーを返すモック APIにあります。