Probar el manejo de HTTP 503 en C# con HttpClient
Qué hace HttpClient con un 503, un DelegatingHandler que respeta Retry-After y tests de xUnit contra una API real.
Ejemplos ejecutados por última vez contra la API el
Un 503 significa que el servicio no está disponible por un tiempo: está sobrecargado, reiniciándose o en mantenimiento. Los buenos clientes esperan y lo vuelven a intentar; los descuidados se caen, reintentan en un bucle cerrado o muestran una pantalla en blanco. Para saber de qué tipo es el tuyo, necesitas un servidor que devuelva 503 cuando se lo pidas. GET /api/status/503 en https://api.testingapis.com lo hace siempre, con una cabecera Retry-After, y ?error=503 hace fallar del mismo modo cualquier endpoint real. Los dos son gratuitos y no necesitan clave. Esta guía muestra qué hace HttpClient con un 503, cómo leer Retry-After, un pequeño handler de reintentos y tests de xUnit para él. Todos los ejemplos se ejecutaron en .NET 10.
Endpoints que devuelven 503
| Petición | Lo que vuelve | Úsalo para probar |
|---|---|---|
GET /api/status/503 | 503 con Retry-After: 5, siempre | Leer Retry-After; rendirse tras N intentos |
GET /api/Products/1?error=503 | 503 con X-Simulated: true, sin Retry-After | Una llamada real de tu app que falla; backoff sin pista |
GET /api/Todos/1?delay=2&error=503 | 503 tras 2 segundos | Estados de carga y timeouts a la vez |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503inicia sesión para verlo | 503 dos veces y luego 200; X-Attempt cuenta | Reintentos que acaban saliendo bien |
$ 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/503Qué hace HttpClient con un 503
GetAsync no lanza una excepción ante un estado de error. Devuelve la respuesta, y te toca a ti comprobarla. El código que va directo a ReadFromJsonAsync intentará deserializar el cuerpo del error como si fueran datos. EnsureSuccessStatusCode y la familia GetFromJsonAsync sí lanzan: una HttpRequestException cuya propiedad StatusCode (desde .NET 5) te dice que fue un 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 se analiza por ti: response.Headers.RetryAfter.Delta cuando el servidor envía segundos y .Date cuando envía una fecha HTTP. El error simulado es distinto. No tiene Retry-After, como muchas caídas reales, así que tu código necesita un valor de reserva:
$ 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}Un handler de reintentos que respeta Retry-After
Un DelegatingHandler se coloca en el pipeline de HttpClient, así que todas las peticiones reciben la misma política sin tocar el código que llama. Este reintenta 503 y 429, espera lo que pida Retry-After (hasta un tope, para que un servidor no pueda dejarte parado una hora), recurre a un backoff exponencial y para tras un número fijo de intentos.
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");Contra /api/status/503, que nunca se recupera, lo intenta tres veces, esperando cada vez el tope de dos segundos porque el servidor pide cinco, y devuelve el último 503. Un endpoint sano pasa a la primera:
attempt 1: 503, waiting 2.0s
attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0sProbar un servicio que vuelve
Rendirse es la mitad del comportamiento. La otra mitad es recuperarse cuando el servicio vuelve. /api/chaos/retry/{key}inicia sesión para verlo falla un número fijo de veces por cada clave y luego acierta, y su cabecera X-Attempt dice cuántas peticiones ha visto. Envía Retry-After: 1, así que el handler de arriba se recupera en unos dos segundos.
$ 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: 3Tests de xUnit para el handler
Se ejecutan contra la API real, así que prueban el comportamiento real de la red: códigos de estado, cabeceras y tiempos. Una clave nueva en cada ejecución evita que el contador de reintentos se cuele de una ejecución a otra. Los tres pasan con xUnit 2.9 en .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);
}
}Los tests que llaman a una API pública dependen de que esté en marcha, así que mantenlos en una categoría aparte de tus tests unitarios. La guía de tests de integración con xUnit lo explica, además de cómo ejecutarlos en GitHub Actions.
Qué comprobar en tu cliente
- Un 503 se trata como error. Nada intenta leer el cuerpo del error como datos.
Retry-Afterse respeta, en sus dos formas, segundos y fecha, con un límite superior.- Sin
Retry-After, las esperas crecen entre intentos en lugar de machacar al servidor. - Los reintentos paran tras un número fijo de intentos, y el error final llega al usuario o a los logs.
- Los errores 4xx que no son 429 no se reintentan; una petición mala sigue siendo mala.
- Las escrituras solo se reintentan cuando repetirlas no puede hacer daño.
Más modos de fallo, como timeouts y respuestas cortadas, en una API simulada que devuelve errores cuando quieras.