Den Umgang mit HTTP 503 in C# mit HttpClient testen
Was HttpClient mit einem 503 macht, ein DelegatingHandler, der Retry-After beachtet, und xUnit-Tests gegen eine Live-API.
Beispiele zuletzt am gegen die API ausgeführt
Ein 503 bedeutet, dass der Dienst vorübergehend nicht verfügbar ist: überlastet, im Neustart oder in Wartung. Gute Clients warten und versuchen es erneut; nachlässige stürzen ab, wiederholen in einer engen Schleife oder zeigen einen leeren Bildschirm. Um herauszufinden, zu welcher Sorte deiner gehört, brauchst du einen Server, der auf Wunsch 503 liefert. GET /api/status/503 auf https://api.testingapis.com tut das immer, mit einem Header Retry-After, und ?error=503 lässt jeden echten Endpunkt genauso scheitern. Beides ist kostenlos und ohne Schlüssel. Dieser Leitfaden zeigt, was HttpClient mit einem 503 macht, wie du Retry-After liest, einen kleinen Retry-Handler und xUnit-Tests dafür. Alle Beispiele liefen auf .NET 10.
Endpunkte, die 503 liefern
| Request | Was zurückkommt | Damit testest du |
|---|---|---|
GET /api/status/503 | 503 mit Retry-After: 5, jedes Mal | Retry-After lesen; nach N Versuchen aufgeben |
GET /api/Products/1?error=503 | 503 mit X-Simulated: true, ohne Retry-After | Einen echten Aufruf deiner App, der scheitert; Backoff ohne Hinweis |
GET /api/Todos/1?delay=2&error=503 | 503 nach 2 Sekunden | Ladezustände und Timeouts zusammen |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503anmelden zum Anzeigen | Zweimal 503, dann 200; X-Attempt zählt | Retries, die am Ende klappen |
$ 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/503Was HttpClient mit einem 503 macht
GetAsync wirft bei einem Fehlerstatus keine Ausnahme. Es gibt die Antwort zurück, und es liegt an dir, sie zu prüfen. Code, der direkt zu ReadFromJsonAsync geht, versucht den Fehler-Body zu deserialisieren, als wären es Daten. EnsureSuccessStatusCode und die Familie GetFromJsonAsync werfen dagegen: eine HttpRequestException, deren Eigenschaft StatusCode (seit .NET 5) dir sagt, dass es ein 503 war.
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 wird für dich geparst: response.Headers.RetryAfter.Delta, wenn der Server Sekunden schickt, .Date, wenn er ein HTTP-Datum schickt. Der simulierte Fehler ist anders. Er hat kein Retry-After, wie viele echte Ausfälle, dein Code braucht also einen Rückfallwert:
$ 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}Ein Retry-Handler, der Retry-After beachtet
Ein DelegatingHandler sitzt in der Pipeline von HttpClient, jeder Request bekommt also dieselbe Richtlinie, ohne dass du den aufrufenden Code anfasst. Dieser wiederholt 503 und 429, wartet so lange, wie Retry-After verlangt (bis zu einer Obergrenze, damit ein Server dich nicht eine Stunde hinhalten kann), fällt auf exponentiellen Backoff zurück und hört nach einer festen Zahl von Versuchen auf.
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");Gegen /api/status/503, das sich nie erholt, versucht er es dreimal, wartet jedes Mal die Obergrenze von zwei Sekunden, weil der Server fünf verlangt, und gibt das letzte 503 zurück. Ein gesunder Endpunkt geht direkt durch:
attempt 1: 503, waiting 2.0s
attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0sEinen Dienst testen, der zurückkommt
Aufgeben ist die Hälfte des Verhaltens. Die andere Hälfte ist, sich zu erholen, wenn der Dienst zurückkommt. /api/chaos/retry/{key}anmelden zum Anzeigen scheitert pro Schlüssel eine festgelegte Anzahl Male und klappt dann, und sein Header X-Attempt sagt, wie viele Requests er gesehen hat. Er sendet Retry-After: 1, der Handler oben erholt sich also in etwa zwei Sekunden.
$ 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: 3xUnit-Tests für den Handler
Sie laufen gegen die Live-API und testen also das echte Verhalten auf der Leitung: Statuscodes, Header und Timing. Ein neuer Schlüssel pro Lauf verhindert, dass der Retry-Zähler zwischen Testläufen durchsickert. Alle drei bestehen mit xUnit 2.9 auf .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);
}
}Tests, die eine öffentliche API aufrufen, hängen davon ab, dass sie läuft, halte sie also in einer eigenen Kategorie getrennt von deinen Unit-Tests. Der Leitfaden zu Integrationstests mit xUnit behandelt das und auch, wie du sie in GitHub Actions ausführst.
Was du in deinem Client prüfen solltest
- Ein 503 wird als Fehler behandelt. Nichts versucht, den Fehler-Body als Daten zu lesen.
Retry-Afterwird beachtet, in beiden Formen, Sekunden und Datum, mit einer Obergrenze.- Ohne
Retry-Afterwerden die Wartezeiten zwischen den Versuchen länger, statt den Server zu bombardieren. - Retries hören nach einer festen Zahl von Versuchen auf, und der letzte Fehler erreicht den Nutzer oder die Logs.
- 4xx-Fehler außer 429 werden nicht wiederholt; ein schlechter Request bleibt schlecht.
- Schreibzugriffe werden nur wiederholt, wenn eine Wiederholung keinen Schaden anrichten kann.
Weitere Fehlerarten, darunter Timeouts und abgeschnittene Antworten, findest du in einer Mock-API, die auf Wunsch Fehler liefert.