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
| Request | Yang kembali | Pakai untuk menguji |
|---|---|---|
GET /api/status/503 | 503 dengan Retry-After: 5, setiap kali | Membaca Retry-After; menyerah setelah N percobaan |
GET /api/Products/1?error=503 | 503 dengan X-Simulated: true, tanpa Retry-After | Panggilan sungguhan aplikasi Anda yang gagal; backoff tanpa petunjuk |
GET /api/Todos/1?delay=2&error=503 | 503 setelah 2 detik | Status memuat dan timeout sekaligus |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503masuk untuk melihat | 503 dua kali, lalu 200; X-Attempt menghitung | Retry yang akhirnya berhasil |
$ 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/503Apa 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.
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 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:
$ 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.
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");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:
attempt 1: 503, waiting 2.0s
attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0sMenguji 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.
$ 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: 3Tes 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.
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);
}
}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-Afterdihormati, 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.