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 付きの 503 | Retry-After の読み取り、N 回でのあきらめ |
GET /api/Products/1?error=503 | X-Simulated: true 付き、Retry-After なしの 503 | アプリが行う実際の呼び出しの失敗、手がかりのないバックオフ |
GET /api/Todos/1?delay=2&error=503 | 2 秒後に 503 | 読み込み中の表示とタイムアウトの組み合わせ |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503サインインして表示 | 503 が 2 回の後に 200。X-Attempt が回数を数える | 最終的に成功するリトライ |
$ 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."}/api/status/503HttpClient は 503 をどう扱うか
GetAsync はエラーステータスでも例外を投げません。レスポンスを返すだけで、確認するのはあなたの役目です。いきなり ReadFromJsonAsync を呼ぶコードは、エラーボディをデータのつもりでデシリアライズしようとします。EnsureSuccessStatusCode と GetFromJsonAsync 系は例外を投げます。HttpRequestException で、その StatusCode プロパティ(.NET 5 以降)を見れば 503 だったとわかります。
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: 503Retry-After は解析済みで渡されます。サーバーが秒数を送れば response.Headers.RetryAfter.Delta、HTTP の日付を送れば .Date です。シミュレートしたエラーは違います。多くの実際の障害と同じく Retry-After がないので、コードには代わりの値が必要です。
$ 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 時間止められないよう上限付き)、なければ指数バックオフに切り替え、決まった回数で 止めます。
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);
}
}
}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 秒で回復します。
$ 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 で通ります。
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にあります。