Tester la gestion du HTTP 503 en C# avec HttpClient

Ce que fait HttpClient d’un 503, un DelegatingHandler qui respecte Retry-After et des tests xUnit contre une API en direct.

Exemples exécutés pour la dernière fois contre l’API le

Un 503 signifie que le service est temporairement indisponible : surchargé, en redémarrage ou en maintenance. Les bons clients attendent et réessaient ; les négligents plantent, réessaient en boucle serrée ou affichent un écran blanc. Pour savoir de quel genre est le vôtre, il vous faut un serveur qui renvoie 503 à la demande. GET /api/status/503 sur https://api.testingapis.com le fait toujours, avec un en-tête Retry-After, et ?error=503 fait échouer de la même façon n’importe quel vrai point d’accès. Les deux sont gratuits et sans clé. Ce guide montre ce que fait HttpClient d’un 503, comment lire Retry-After, un petit handler de nouvelles tentatives et des tests xUnit pour lui. Tous les exemples ont tourné sur .NET 10.

Les points d’accès qui renvoient 503

RequêteCe qui revientPour tester
GET /api/status/503503 avec Retry-After: 5, à chaque foisLa lecture de Retry-After ; l’abandon après N tentatives
GET /api/Products/1?error=503503 avec X-Simulated: true, sans Retry-AfterUn vrai appel de votre application qui échoue ; un backoff sans indication
GET /api/Todos/1?delay=2&error=503503 au bout de 2 secondesLes états de chargement et les timeouts ensemble
GET /api/chaos/retry/{key}?succeedAfter=2&failStatus=503connectez-vous pour voir503 deux fois, puis 200 ; X-Attempt compteDes nouvelles tentatives qui finissent par réussir
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

Ce que fait HttpClient d’un 503

GetAsync ne lève pas d’exception sur un statut d’erreur. Il renvoie la réponse, et c’est à vous de la vérifier. Un code qui passe directement à ReadFromJsonAsync essaiera de désérialiser le corps de l’erreur comme s’il s’agissait de données. EnsureSuccessStatusCode et la famille GetFromJsonAsync lèvent bien une exception : une HttpRequestException dont la propriété StatusCode (depuis .NET 5) indique qu’il s’agissait d’un 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}");
}
Sortie
Status: 503 ServiceUnavailable
IsSuccessStatusCode: False
Retry-After: 5 seconds
HttpRequestException: StatusCode=503
GetFromJsonAsync threw: 503

Retry-After est analysé pour vous : response.Headers.RetryAfter.Delta quand le serveur envoie des secondes, .Date quand il envoie une date HTTP. L’erreur simulée est différente. Elle n’a pas de Retry-After, comme bien des pannes réelles, donc votre code a besoin d’une valeur de repli :

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}

Un handler de nouvelles tentatives qui respecte Retry-After

Un DelegatingHandler s’insère dans le pipeline de HttpClient : chaque requête reçoit la même politique sans toucher au code appelant. Celui-ci relance les 503 et 429, attend aussi longtemps que le demande Retry-After (jusqu’à un plafond, pour qu’un serveur ne puisse pas vous bloquer une heure), se rabat sur un backoff exponentiel et s’arrête après un nombre fixe de tentatives.

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");

Contre /api/status/503, qui ne se rétablit jamais, il essaie trois fois en attendant à chaque fois le plafond de deux secondes, puisque le serveur en demande cinq, puis renvoie le dernier 503. Un point d’accès en bonne santé passe du premier coup :

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

Tester un service qui revient

Abandonner, c’est la moitié du comportement. L’autre moitié, c’est se rétablir quand le service revient. /api/chaos/retry/{key}connectez-vous pour voir échoue un nombre de fois donné pour chaque clé puis réussit, et son en-tête X-Attempt indique combien de requêtes il a reçues. Il envoie Retry-After: 1, donc le handler ci-dessus se rétablit en deux secondes environ.

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
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

Des tests xUnit pour le handler

Ils tournent contre l’API en direct et testent donc le vrai comportement réseau : codes de statut, en-têtes et délais. Une clé neuve à chaque exécution évite que le compteur de tentatives déborde d’une exécution à l’autre. Les trois passent avec xUnit 2.9 sur .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);
    }
}
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

Les tests qui appellent une API publique dépendent de sa disponibilité, gardez-les donc dans une catégorie séparée de vos tests unitaires. Le guide des tests d’intégration avec xUnit l’explique, ainsi que leur exécution dans GitHub Actions.

Ce qu’il faut vérifier dans votre client

  • Un 503 est traité comme une erreur. Rien n’essaie de lire le corps de l’erreur comme des données.
  • Retry-After est respecté, sous ses deux formes, secondes et date, avec une limite haute.
  • Sans Retry-After, les attentes s’allongent entre les tentatives au lieu de marteler le serveur.
  • Les nouvelles tentatives s’arrêtent après un nombre fixe, et l’erreur finale parvient à l’utilisateur ou aux logs.
  • Les erreurs 4xx autres que 429 ne sont pas relancées ; une mauvaise requête reste mauvaise.
  • Les écritures ne sont relancées que si les répéter ne peut pas causer de dégâts.

D’autres modes de panne, dont les timeouts et les réponses tronquées, sont dans une API simulée qui renvoie des erreurs à la demande.