Menguji penanganan HTTP 503 di C# dengan HttpClient

Apa yang dilakukan HttpClient pada 503, DelegatingHandler yang menghormati Retry-After, dan tes xUnit terhadap API langsung.

Contoh terakhir dijalankan terhadap API pada

503 berarti layanan sementara tidak tersedia: kelebihan beban, sedang dimulai ulang, atau dalam pemeliharaan. Klien yang baik menunggu lalu mencoba lagi; klien yang ceroboh crash, mencoba ulang dalam loop rapat, atau menampilkan layar kosong. Untuk menguji klien Anda termasuk jenis yang mana, Anda butuh server yang mengembalikan 503 saat diminta. GET /api/status/503 di https://api.testingapis.com selalu melakukannya, dengan header Retry-After, dan ?error=503 membuat endpoint sungguhan mana pun gagal dengan cara yang sama. Keduanya gratis dan tanpa key. Panduan ini menunjukkan apa yang dilakukan HttpClient pada 503, cara membaca Retry-After, handler retry kecil, dan tes xUnit untuknya. Setiap contoh dijalankan di .NET 10.

Endpoint yang mengembalikan 503

RequestYang kembaliPakai untuk menguji
GET /api/status/503503 dengan Retry-After: 5, setiap kaliMembaca Retry-After; menyerah setelah N percobaan
GET /api/Products/1?error=503503 dengan X-Simulated: true, tanpa Retry-AfterPanggilan sungguhan aplikasi Anda yang gagal; backoff tanpa petunjuk
GET /api/Todos/1?delay=2&error=503503 setelah 2 detikStatus memuat dan timeout sekaligus
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503masuk untuk melihat503 dua kali, lalu 200; X-Attempt menghitungRetry yang akhirnya berhasil
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

Apa yang dilakukan HttpClient pada 503

GetAsync tidak melempar exception pada status error. Ia mengembalikan response, dan Anda yang harus memeriksanya. Kode yang langsung memanggil ReadFromJsonAsync akan mencoba men-deserialize body error seolah-olah itu data. EnsureSuccessStatusCode dan keluarga GetFromJsonAsync memang melempar: HttpRequestException yang properti StatusCode-nya (sejak .NET 5) memberi tahu bahwa itu 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}");
}
Output
Status: 503 ServiceUnavailable
IsSuccessStatusCode: False
Retry-After: 5 seconds
HttpRequestException: StatusCode=503
GetFromJsonAsync threw: 503

Retry-After sudah di-parse untuk Anda: response.Headers.RetryAfter.Delta saat server mengirim detik, .Date saat server mengirim tanggal HTTP. Error simulasi berbeda. Ia tidak punya Retry-After, seperti banyak gangguan sungguhan, jadi kode Anda butuh nilai cadangan:

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}

Handler retry yang menghormati Retry-After

DelegatingHandler berada di pipeline HttpClient, jadi setiap request mendapat kebijakan yang sama tanpa menyentuh kode pemanggil. Handler ini mengulang 503 dan 429, menunggu selama yang diminta Retry-After (sampai batas tertentu, agar server tidak bisa menahan Anda satu jam), beralih ke backoff eksponensial, dan berhenti setelah sejumlah percobaan tetap.

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");

Terhadap /api/status/503, yang tidak pernah pulih, handler mencoba tiga kali, menunggu batas dua detik setiap kali karena server meminta lima, lalu mengembalikan 503 terakhir. Endpoint yang sehat langsung lewat:

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

Menguji layanan yang kembali pulih

Menyerah hanyalah separuh perilakunya. Separuh lainnya adalah pulih saat layanan kembali. /api/chaos/retry/{key}masuk untuk melihat gagal sejumlah kali tertentu untuk setiap key, lalu berhasil, dan header X-Attempt-nya menyebut berapa request yang sudah ia terima. Ia mengirim Retry-After: 1, jadi handler di atas pulih dalam sekitar dua detik.

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
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Tes xUnit untuk handler

Tes ini berjalan terhadap API langsung, jadi menguji perilaku jaringan yang sebenarnya: kode status, header, dan waktu. Key baru di setiap run mencegah penghitung retry bocor antar-run. Ketiganya lulus dengan xUnit 2.9 di .NET 10.

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);
    }
}
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Tes yang memanggil API publik bergantung pada API itu sedang aktif, jadi pisahkan dalam kategori tersendiri dari unit test Anda. Panduan integration test dengan xUnit membahasnya, termasuk cara menjalankannya di GitHub Actions.

Yang perlu diperiksa di klien Anda

  • 503 diperlakukan sebagai error. Tidak ada yang mencoba membaca body error sebagai data.
  • Retry-After dihormati, dalam bentuk detik maupun tanggal, dengan batas atas.
  • Tanpa Retry-After, jeda antar-percobaan makin panjang alih-alih menghantam server terus-menerus.
  • Retry berhenti setelah sejumlah percobaan tetap, dan error terakhir sampai ke pengguna atau log.
  • Error 4xx selain 429 tidak diulang; request yang buruk tetap buruk.
  • Penulisan hanya diulang jika mengulanginya tidak bisa menimbulkan kerusakan.

Mode kegagalan lain, termasuk timeout dan response terpotong, ada di mock API yang mengembalikan error sesuai permintaan.