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çãoO que voltaUse para testar
GET /api/status/503503 com Retry-After: 5, sempreLer o Retry-After; desistir depois de N tentativas
GET /api/Products/1?error=503503 com X-Simulated: true, sem Retry-AfterUma chamada real do seu app falhando; backoff sem dica
GET /api/Todos/1?delay=2&error=503503 depois de 2 segundosEstados de carregamento e timeouts juntos
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503entre para ver503 duas vezes, depois 200; X-Attempt contaRetries que acabam dando certo
Terminal
$ 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."}
GET/api/status/503

O 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.

Program.cs
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}");
}
Saída
Status: 503 ServiceUnavailable
IsSuccessStatusCode: False
Retry-After: 5 seconds
HttpRequestException: StatusCode=503
GetFromJsonAsync threw: 503

O 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:

Terminal
$ 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.

RetryHandler.cs
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);
        }
    }
}
Program.cs
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:

Saída
attempt 1: 503, waiting 2.0s
  attempt 2: 503, waiting 2.0s
After retries: 503 in 4.3s
Healthy endpoint: 200 in 0.0s

Testar 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.

Terminal
$ 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: 3
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

Testes 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.

RetryHandlerTests.cs
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);
    }
}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.