Test HTTP 503 handling in C# with HttpClient
What HttpClient does with a 503, a DelegatingHandler that honours Retry-After, and xUnit tests against a live API.
Examples last run against the API on
A 503 means the service is temporarily unavailable: overloaded, restarting or in maintenance. Good clients wait and try again; careless ones crash, retry in a tight loop, or show a blank screen. To test which kind yours is, you need a server that returns 503 when you ask. GET /api/status/503 on https://api.testingapis.com always does, with a Retry-After header, and ?error=503 makes any real endpoint fail the same way. Both are free and need no key. This guide shows what HttpClient does with a 503, how to read Retry-After, a small retry handler, and xUnit tests for it. Every example was run on .NET 10.
Endpoints that return 503
| Request | What comes back | Use it to test |
|---|---|---|
GET /api/status/503 | 503 with Retry-After: 5, every time | Reading Retry-After; giving up after N attempts |
GET /api/Products/1?error=503 | 503 with X-Simulated: true, no Retry-After | A real call your app makes, failing; backoff without a hint |
GET /api/Todos/1?delay=2&error=503 | 503 after 2 seconds | Loading states and timeouts together |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503sign in to see | 503 twice, then 200; X-Attempt counts | Retries that eventually succeed |
$ 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/503What HttpClient does with a 503
GetAsyncdoesn't throw on an error status. It returns the response, and it's up to you to check it. Code that goes straight to ReadFromJsonAsync will try to deserialize the error body as if it were data. EnsureSuccessStatusCode and the GetFromJsonAsync family do throw: an HttpRequestException whose StatusCode property (since .NET 5) tells you it was a 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 is parsed for you: response.Headers.RetryAfter.Delta when the server sends seconds, .Date when it sends an HTTP date. The simulated error is different. It has no Retry-After, like many real outages, so your code needs a fallback:
$ 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}A retry handler that respects Retry-After
A DelegatingHandler sits in the HttpClient pipeline, so every request gets the same policy without touching the calling code. This one retries 503 and 429, waits as long as Retry-Afterasks (up to a cap, so a server can't stall you for an hour), falls back to exponential backoff, and stops after a fixed number of attempts.
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");Against /api/status/503, which never recovers, it tries three times, waiting the two-second cap each time because the server asks for five, and returns the last 503. A healthy endpoint goes straight through:
attempt 1: 503, waiting 2.0s
attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0sTest a service that comes back
Giving up is half the behaviour. The other half is recovering when the service returns. /api/chaos/retry/{key}sign in to see fails a set number of times for each key, then succeeds, and its X-Attempt header says how many requests it has seen. It sends Retry-After: 1, so the handler above recovers in about two seconds.
$ 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 for the handler
These run against the live API, so they test the real wire behaviour: status codes, headers and timing. A fresh key per run keeps the retry counter from leaking between test runs. All three pass with xUnit 2.9 on .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 that call a public API depend on it being up, so keep them in a separate category from your unit tests. The xUnit integration testing guide covers that, plus running them in GitHub Actions.
What to check in your client
- A 503 is treated as an error. Nothing tries to read the error body as data.
Retry-Afteris honoured, in both its seconds and date forms, with an upper limit.- Without
Retry-After, waits grow between attempts instead of hammering the server. - Retries stop after a fixed number of attempts, and the final error reaches the user or the logs.
- 4xx errors other than 429 are not retried; a bad request stays bad.
- Writes are only retried when repeating them can't do damage.
More failure modes, including timeouts and cut-off responses, are in a mock API that returns errors on demand.