Testar o tratamento de HTTP 503 em C# com HttpClient
O que o HttpClient faz com um 503, um DelegatingHandler que respeita o Retry-After e testes xUnit contra uma API ao vivo.
Exemplos executados pela última vez contra a API em
Um 503 quer dizer que o serviço está temporariamente indisponível: sobrecarregado, reiniciando ou em manutenção. Bons clientes esperam e tentam de novo; os descuidados quebram, repetem num loop apertado ou mostram uma tela em branco. Para saber de que tipo é o seu, você precisa de um servidor que devolva 503 quando você pedir. GET /api/status/503 em https://api.testingapis.com sempre devolve, com um cabeçalho Retry-After, e ?error=503 faz qualquer endpoint real falhar do mesmo jeito. Os dois são gratuitos e não precisam de chave. Este guia mostra o que o HttpClient faz com um 503, como ler o Retry-After, um pequeno handler de retry e testes xUnit para ele. Todos os exemplos rodaram no .NET 10.
Endpoints que devolvem 503
| Requisição | O que volta | Use para testar |
|---|---|---|
GET /api/status/503 | 503 com Retry-After: 5, sempre | Ler o Retry-After; desistir depois de N tentativas |
GET /api/Products/1?error=503 | 503 com X-Simulated: true, sem Retry-After | Uma chamada real do seu app falhando; backoff sem dica |
GET /api/Todos/1?delay=2&error=503 | 503 depois de 2 segundos | Estados de carregamento e timeouts juntos |
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503entre para ver | 503 duas vezes, depois 200; X-Attempt conta | Retries que acabam dando certo |
$ 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/503O que o HttpClient faz com um 503
O GetAsync não lança exceção em um status de erro. Ele devolve a resposta, e cabe a você conferir. Um código que vai direto para o ReadFromJsonAsync vai tentar desserializar o corpo do erro como se fosse dado. EnsureSuccessStatusCode e a família GetFromJsonAsync lançam: uma HttpRequestException cuja propriedade StatusCode (desde o .NET 5) diz que foi um 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: 503O Retry-After já vem lido para você: response.Headers.RetryAfter.Delta quando o servidor manda segundos, .Date quando manda uma data HTTP. O erro simulado é diferente. Ele não tem Retry-After, como muitas quedas reais, então o seu código precisa de um valor padrão:
$ 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}Um handler de retry que respeita o Retry-After
Um DelegatingHandler fica no pipeline do HttpClient, então toda requisição recebe a mesma política sem mexer no código que faz a chamada. Este repete 503 e 429, espera o quanto o Retry-After pedir (até um limite, para um servidor não te travar por uma hora), recorre a backoff exponencial e para depois de um número fixo de tentativas.
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, ele tenta três vezes, esperando o limite de dois segundos a cada vez porque o servidor pede cinco, e devolve o último 503. Um endpoint saudável passa direto:
attempt 1: 503, waiting 2.0s
attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0sTestar um serviço que volta
Desistir é metade do comportamento. A outra metade é se recuperar quando o serviço volta. /api/chaos/retry/{key}entre para ver falha um número definido de vezes para cada chave e depois dá certo, e o cabeçalho X-Attempt diz quantas requisições ele viu. Ele manda Retry-After: 1, então o handler acima se recupera em uns dois 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: 3Testes xUnit para o handler
Eles rodam contra a API ao vivo, então testam o comportamento real da rede: códigos de status, cabeçalhos e tempos. Uma chave nova a cada execução impede que o contador de retries vaze de uma execução para outra. Os três passam com xUnit 2.9 no .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);
}
}Testes que chamam uma API pública dependem de ela estar no ar, então mantenha-os em uma categoria separada dos seus testes unitários. O guia de testes de integração com xUnit explica isso, além de como rodá-los no GitHub Actions.
O que verificar no seu cliente
- Um 503 é tratado como erro. Nada tenta ler o corpo do erro como dado.
- O
Retry-Afteré respeitado, tanto em segundos quanto em data, com um limite máximo. - Sem
Retry-After, as esperas aumentam entre as tentativas em vez de martelar o servidor. - Os retries param depois de um número fixo de tentativas, e o erro final chega ao usuário ou aos logs.
- Erros 4xx que não sejam 429 não são repetidos; uma requisição ruim continua ruim.
- Escritas só são repetidas quando repeti-las não pode causar dano.
Mais modos de falha, incluindo timeouts e respostas cortadas, em uma API simulada que devolve erros quando você quiser.