ASP.NET Core

Résilience et performance

Cache, HttpClientFactory, Polly, health checks, IHostedService.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 17 min

Une API tient rarement seule : elle attend une base, appelle d'autres services, et tourne sous un orchestrateur qui la démarre, l'arrête et décide si elle reçoit du trafic. Sa tenue sous charge dépend donc de cinq mécanismes : ne pas refaire un travail déjà fait (cache de données, cache de sortie), appeler les autres sans épuiser la machine (IHttpClientFactory), survivre à leurs pannes (Polly), dire à l'orchestrateur dans quel état elle est (health checks), et faire du travail hors des requêtes sans le perdre à l'arrêt (services hébergés). Le pipeline est décrit dans « Construire une API », les durées de vie des services dans « Injection de dépendances », l'authentification dans « Authentification et autorisation », la limitation de débit côté serveur dans « Qualité d'API ». Tout ce qui suit a tourné sur .NET 10 (runtime 10.0.8, celui de la machine de rédaction), en Production.

Mettre des données en cache

IMemoryCache garde des objets dans la mémoire du processus : aucun coût de sérialisation, mais chaque instance a le sien, et un redémarrage le vide. AddMemoryCache ne lui fixe aucune taille : SizeLimit vaut null, et un cache dont les clés viennent des requêtes grossit sans borne. Si l'on fixe une limite, chaque entrée doit déclarer sa taille, sinon Set lève Cache entry must specify a value for Size when SizeLimit is set.

Le piège tient à un nom : GetOrCreateAsync ressemble à une opération atomique, et n'en est pas une. Tous les appelants qui trouvent la clé absente au même moment lancent chacun la fabrique.

using Microsoft.Extensions.Caching.Memory;

// Projet Microsoft.NET.Sdk.Web : l'hote, IMemoryCache et l'injection sont
// dans le framework partage.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<Catalogue>();
builder.Services.AddSingleton<Tarifs>();
using var host = builder.Build();

var tarifs = host.Services.GetRequiredService<Tarifs>();
var catalogue = host.Services.GetRequiredService<Catalogue>();

// Cinquante requetes arrivent ensemble sur une cle absente : au demarrage, ou a
// l'expiration d'une entree tres demandee.
var prix = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => tarifs.LireAsync("USB-64")));
Console.WriteLine($"prix : {prix[0]}, appels a la source : {catalogue.Appels}");

// prix : 9,90 (culture fr-FR ; 9.90 en culture invariante), appels a la source : 50

public sealed class Catalogue
{
    private int appels;
    public int Appels => appels;

    // Tient lieu d'une requete SQL de 200 ms.
    public async Task<decimal> PrixAsync(string reference, CancellationToken ct = default)
    {
        Interlocked.Increment(ref appels);
        await Task.Delay(200, ct);
        return 9.90m;
    }
}

public sealed class Tarifs(IMemoryCache cache, Catalogue catalogue)
{
    // GetOrCreateAsync n'a rien d'atomique : chaque appelant qui trouve la cle
    // absente lance sa propre fabrique.
    public async Task<decimal> LireAsync(string reference) =>
        await cache.GetOrCreateAsync($"tarif:{reference}", entree =>
        {
            entree.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return catalogue.PrixAsync(reference);
        });
}

Cinquante requêtes, cinquante requêtes SQL. Au moment précis où une clé très demandée expire, la base reçoit d'un coup la charge que le cache devait lui épargner : c'est la ruée sur le cache (cache stampede). HybridCache, apparu avec .NET 9, en protège : pour une clé donnée, un seul appelant exécute la fabrique et les autres attendent son résultat. Il y ajoute un premier niveau en mémoire, un second niveau qui est l'IDistributedCache enregistré s'il y en a un, et la sérialisation par System.Text.Json par défaut. Le paquet Microsoft.Extensions.Caching.Hybrid est stable depuis la 9.3.0 (mars 2025) ; la version courante est la 10.10.0 (9 septembre 2026). Le type abstrait HybridCache est dans le framework, son implémentation dans le paquet : sans lui, AddHybridCache ne compile pas (CS1061 sur ASP.NET Core 10.0.8). La page HybridCache de la documentation commence d'ailleurs par installer le paquet ; seule sa section « Custom HybridCache implementations » dit, au 26 septembre 2026, l'implémentation incluse dans le framework partagé.

using Microsoft.Extensions.Caching.Hybrid;

var builder = Host.CreateApplicationBuilder(args);

// Paquet Microsoft.Extensions.Caching.Hybrid 10.10.0. Si un IDistributedCache
// est enregistre (Redis, SQL Server...), il devient le second niveau sans
// autre ligne a ecrire ; sans lui, le cache reste en memoire.
builder.Services.AddHybridCache();
builder.Services.AddSingleton<Catalogue>(); // celui du premier exemple
builder.Services.AddSingleton<Tarifs>();
using var host = builder.Build();

var tarifs = host.Services.GetRequiredService<Tarifs>();
var catalogue = host.Services.GetRequiredService<Catalogue>();

var prix = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => tarifs.LireAsync("USB-64")));
Console.WriteLine($"prix : {prix[0]}, appels a la source : {catalogue.Appels}");

// Les tarifs ont change en base : tout ce qui porte l'etiquette est perime.
await tarifs.InvaliderAsync();
await tarifs.LireAsync("USB-64");
Console.WriteLine($"apres invalidation : {catalogue.Appels}");

// prix : 9,90 (culture fr-FR), appels a la source : 1
// apres invalidation : 2

public sealed class Tarifs(HybridCache cache, Catalogue catalogue)
{
    private static readonly HybridCacheEntryOptions Duree = new()
    {
        Expiration = TimeSpan.FromMinutes(5),           // duree totale, second niveau compris
        LocalCacheExpiration = TimeSpan.FromMinutes(1), // premier niveau, en memoire
    };

    public async Task<decimal> LireAsync(string reference, CancellationToken ct = default) =>
        await cache.GetOrCreateAsync(
            $"tarif:{reference}",
            async annulation => await catalogue.PrixAsync(reference, annulation),
            Duree,
            tags: ["tarifs"],
            cancellationToken: ct);

    public ValueTask InvaliderAsync(CancellationToken ct = default) =>
        cache.RemoveByTagAsync("tarifs", ct);
}

IDistributedCache, l'interface des caches hors processus comme Redis ou SQL Server, est ce second niveau. Utilisée seule, elle partage le cache entre instances et le fait survivre à leur redémarrage, mais n'apporte ni protection contre la ruée ni GetOrCreate : elle ne connaît que des octets, et la sérialisation est à la charge de l'appelant. Écrite à la main, la lecture reproduit la faute du premier exemple.

using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

var builder = Host.CreateApplicationBuilder(args);

// En production : AddStackExchangeRedisCache (paquet
// Microsoft.Extensions.Caching.StackExchangeRedis) ou AddDistributedSqlServerCache.
// L'implementation en memoire a la meme interface, mais ne partage rien.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSingleton<Catalogue>(); // celui du premier exemple
builder.Services.AddSingleton<TarifsDistribues>();
using var host = builder.Build();

var tarifs = host.Services.GetRequiredService<TarifsDistribues>();
var catalogue = host.Services.GetRequiredService<Catalogue>();

var prix = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => tarifs.LireAsync("USB-64")));
Console.WriteLine($"prix : {prix[0]}, appels a la source : {catalogue.Appels}");

// prix : 9,90 (culture fr-FR), appels a la source : 50

public sealed class TarifsDistribues(IDistributedCache cache, Catalogue catalogue)
{
    public async Task<decimal> LireAsync(string reference, CancellationToken ct = default)
    {
        var cle = $"tarif:{reference}";

        // L'interface ne connait que des octets : la serialisation est a notre charge.
        var octets = await cache.GetAsync(cle, ct);
        if (octets is not null) return JsonSerializer.Deserialize<decimal>(octets);

        var prix = await catalogue.PrixAsync(reference, ct);
        await cache.SetAsync(cle, JsonSerializer.SerializeToUtf8Bytes(prix),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) },
            ct);
        return prix;
    }
}

Deux limites de HybridCache sont à connaître. La coordination ne vaut qu'à l'intérieur d'une instance : trois serveurs peuvent encore lancer trois fabriques. Et l'invalidation par étiquette est logique — elle marque comme périmé tout ce qui a été créé avant elle — et n'atteint pas le premier niveau des autres serveurs, qui garderont l'ancienne valeur jusqu'à LocalCacheExpiration : c'est la raison de le garder court.

Le cache de sortie

Le cache de données garde un objet ; le cache de sortie garde la réponse HTTP entière et la resservit sans exécuter le gestionnaire. AddOutputCache et UseOutputCache le rendent disponible, mais rien n'est mis en cache tant qu'aucune politique n'est posée sur un point de terminaison, par CacheOutput ou [OutputCache], ou sur tous par une politique de base (AddBasePolicy). Il ne faut pas le confondre avec UseResponseCaching, plus ancien, qui suit les en-têtes Cache-Control comme le ferait un proxy, si bien qu'un client peut le contourner : avec le cache de sortie, c'est le serveur qui décide.

using Microsoft.AspNetCore.OutputCaching;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOutputCache(options =>
{
    // 30 secondes, une entree par valeur de ?ville, une etiquette pour
    // invalider tout le groupe d'un coup.
    options.AddPolicy("Meteo", politique => politique
        .Expire(TimeSpan.FromSeconds(30))
        .SetVaryByQuery("ville")
        .Tag("meteo"));
});

builder.Services.AddSingleton<Compteur>();

var app = builder.Build();

// Apres UseCors, UseAuthentication et UseAuthorization quand il y en a.
app.UseOutputCache();

app.MapGet("/previsions", async (string ville, Compteur compteur) =>
{
    var calcul = Interlocked.Increment(ref compteur.Valeur);
    await Task.Delay(300); // un calcul couteux, ou un appel amont
    return new { ville, calcul, instant = DateTime.UtcNow.ToString("HH:mm:ss.fff") };
}).CacheOutput("Meteo");

// Les donnees ont change : on invalide l'etiquette, pas chaque URL.
app.MapPost("/previsions/invalidation", async (IOutputCacheStore cache, CancellationToken ct) =>
{
    await cache.EvictByTagAsync("meteo", ct);
    return TypedResults.NoContent();
});

app.Run();

public sealed class Compteur
{
    public int Valeur;
}
# dotnet run --no-launch-profile ; en-tetes reduits a ce qui change.
curl -i "$API/previsions?ville=Lyon"
# HTTP/1.1 200 OK
# {"ville":"Lyon","calcul":1,"instant":"23:08:25.647"}

curl -i "$API/previsions?ville=Lyon"
# HTTP/1.1 200 OK
# Age: 0                      <- servie par le cache : le gestionnaire n'a pas tourne
# {"ville":"Lyon","calcul":1,"instant":"23:08:25.647"}

curl "$API/previsions?ville=Lyon&page=2"          # page ne fait pas partie de la cle
# {"ville":"Lyon","calcul":1,...}
curl -H "Cache-Control: no-cache" "$API/previsions?ville=Lyon"   # ignore : c'est le serveur qui decide
# {"ville":"Lyon","calcul":1,...}
curl "$API/previsions?ville=Paris"
# {"ville":"Paris","calcul":2,...}
curl -H "Authorization: Bearer x" "$API/previsions?ville=Lyon"    # requete authentifiee
# {"ville":"Lyon","calcul":3,...}   <- calculee, ni lue ni ecrite dans le cache

curl -X POST "$API/previsions/invalidation"        # 204
curl "$API/previsions?ville=Lyon"
# {"ville":"Lyon","calcul":4,...}

# 20 requetes simultanees sur ?ville=Nice, cache vide : un seul calcul (5),
# les 19 autres attendent son resultat.

La politique par défaut, qu'une politique nommée reprend, ne met en cache que les GET et HEAD qui répondent 200, jamais une réponse qui pose un cookie, jamais une requête authentifiée ; l'expiration par défaut est de 60 secondes. La clé comprend toute l'URL, chaîne de requête incluse, et SetVaryByQuery la restreint : sans elle, chaque valeur de page créerait une entrée. Le verrouillage est actif par défaut, d'où un seul calcul pour vingt requêtes simultanées. L'ordre des middlewares compte : placé avant UseAuthentication et UseAuthorization, le cache peut servir à un appelant non autorisé ce qui a été calculé pour un autre. Le stockage est en mémoire, par instance ; le paquet Microsoft.AspNetCore.OutputCaching.StackExchangeRedis le partage depuis .NET 8.

HttpClient : une fabrique, pas un new

HttpClient implémente IDisposable, d'où le réflexe d'en créer un par requête dans un using. Le libérer ferme sa connexion, et TCP impose au côté qui ferme le premier de garder la socket en TIME_WAIT — deux minutes par défaut sous Windows — pour ne pas confondre des paquets en retard avec ceux d'une nouvelle connexion. Pendant ce temps, le port local reste pris. Le premier exemple suit ce réflexe, le second passe par la fabrique.

var app = WebApplication.CreateBuilder(args).Build();

app.MapGet("/meteo/{ville}", async (string ville) =>
{
    // Un client neuf par requete, libere a la fin : sa connexion part avec lui.
    using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5733") };
    return await client.GetStringAsync($"/meteo/{Uri.EscapeDataString(ville)}");
});

app.Run();
var builder = WebApplication.CreateBuilder(args);

// Client type. La fabrique cree un HttpClient a chaque resolution, mais tous
// partagent un gestionnaire, donc un pool de connexions, recycle toutes les
// deux minutes : les connexions servent longtemps, un changement de DNS finit
// par etre vu.
builder.Services.AddHttpClient<ClientMeteo>(client =>
    client.BaseAddress = new Uri("http://localhost:5733"));

var app = builder.Build();

app.MapGet("/meteo/{ville}", (string ville, ClientMeteo meteo, CancellationToken ct) =>
    meteo.LireAsync(ville, ct));

app.Run();

public sealed class ClientMeteo(HttpClient http)
{
    public Task<string> LireAsync(string ville, CancellationToken ct) =>
        http.GetStringAsync($"/meteo/{Uri.EscapeDataString(ville)}", ct);
}
# L'API sur :5732, l'amont sur :5733 (Windows 11, runtime 10.0.8 de la
# machine de redaction).
# 2 000 requetes GET /meteo/Lyon, 20 en vol, depuis un client qui garde ses
# connexions ouvertes ; puis les connexions de l'API vers l'amont :
netstat -ano | grep -E ':[0-9]+ +[^ ]*:5733 ' | grep -c TIME_WAIT

# new HttpClient() par requete : de 3,7 a 5,8 s selon l'execution
#   2 000 sockets en TIME_WAIT, 0 connexion etablie ; disparues 2 minutes plus tard
# IHttpClientFactory : de 1,4 a 2,8 s
#   aucune nouvelle socket en TIME_WAIT, 20 connexions etablies, reprises par
#   la serie suivante

netsh int ipv4 show dynamicport tcp
# Port de demarrage   : 49152
# Nombre de ports     : 16384

Deux mille sockets en quelques secondes, chacune tenant un port pendant deux minutes : à ce rythme, les 16 384 ports éphémères de Windows seraient épuisés en moins d'une minute, et les appels suivants échoueraient avant même de partir. Le réflexe inverse, un HttpClient statique unique, garde ses connexions tant qu'elles restent actives et, sous charge, ne voit jamais un changement de DNS, sauf à lui donner un SocketsHttpHandler dont PooledConnectionLifetime est borné.

IHttpClientFactory règle les deux problèmes : les HttpClient qu'il fournit sont légers, le gestionnaire et son pool de connexions sont partagés et recyclés (HandlerLifetime, deux minutes par défaut). Un client typé est enregistré comme transient : l'injecter dans un singleton fige son gestionnaire pour toujours, et ramène au client statique. HttpClient.Timeout vaut 100 secondes par défaut ; « Construire une API » montre un client nommé qui le règle.

Résister aux pannes des autres

Un appel distant échoue parfois pour une raison passagère : un redémarrage, une surcharge, un paquet perdu. Le paquet Microsoft.Extensions.Http.Resilience (10.10.0) insère dans la fabrique un gestionnaire qui exécute l'appel dans un pipeline Polly 8 : une suite de stratégies emboîtées, là où Polly 7 composait des Policy. L'ancien Microsoft.Extensions.Http.Polly et son AddPolicyHandler sont déclarés dépréciés par la documentation. La 10.0.12, publiée en septembre 2026, ne porte pas le marquage officiel de NuGet (aucun avertissement à la restauration), mais son README la déclare dépréciée.

var builder = WebApplication.CreateBuilder(args);

// Paquet Microsoft.Extensions.Http.Resilience 10.10.0, bati sur Polly 8.
builder.Services.AddHttpClient<ClientMeteo>(client =>
        client.BaseAddress = new Uri("http://localhost:5733"))
    .AddStandardResilienceHandler();

var app = builder.Build();

app.MapGet("/meteo/{ville}", (string ville, ClientMeteo meteo, CancellationToken ct) =>
    meteo.LireAsync(ville, ct));

app.Run();

public sealed class ClientMeteo(HttpClient http)
{
    public Task<string> LireAsync(string ville, CancellationToken ct) =>
        http.GetStringAsync($"/meteo/{Uri.EscapeDataString(ville)}", ct);
}
# L'amont repond 503, 503, puis 200. GET /meteo/Lyon rend 200 en 6,7 s.
# Journal de l'API, extrait (Polly journalise chaque tentative sans rien configurer) :
warn: Polly[3]
      Execution attempt. Source: 'ClientMeteo-standard//Standard-Retry', Operation Key: '',
      Result: '503', Handled: 'True', Attempt: '0', Execution Time: 490,1045ms
warn: Polly[0]
      Resilience event occurred. EventName: 'OnRetry', Source: 'ClientMeteo-standard//Standard-Retry',
      Operation Key: '', Result: '503'
warn: Polly[3]
      Execution attempt. Source: 'ClientMeteo-standard//Standard-Retry', Operation Key: '',
      Result: '503', Handled: 'True', Attempt: '1', Execution Time: 20,9454ms
info: Polly[3]
      Execution attempt. Source: 'ClientMeteo-standard//Standard-Retry', Operation Key: '',
      Result: '200', Handled: 'False', Attempt: '2', Execution Time: 18,622ms
info: System.Net.Http.HttpClient.ClientMeteo.LogicalHandler[101]
      End processing HTTP request after 6050.5407ms - 200

Une ligne installe cinq stratégies, de l'extérieur vers l'intérieur. Les valeurs sont celles de HttpStandardResilienceOptions en 10.10.0 :

StratégieDéfaut
Limiteur de concurrence1 000 appels simultanés, file de 0
Délai total30 s, réessais compris
Réessai3 réessais, exponentiel à partir de 2 s, avec gigue ; respecte Retry-After
Disjoncteur10 % d'échecs sur au moins 100 appels en 30 s ; ouvert 5 s
Délai par tentative10 s

Le réessai et le disjoncteur traitent les statuts 5xx, 408 et 429, ainsi que HttpRequestException et TimeoutRejectedException. La gigue rend les attentes irrégulières — le même appel a pris 3,6 s à une exécution, 6,7 s à la suivante — pour que mille clients ne reviennent pas à la même milliseconde. Le réessai s'applique à toutes les méthodes HTTP, et c'est son piège : un POST dont la réponse tarde a peut-être déjà produit son effet.

using System.Net;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<ClientPaiements>(client =>
        client.BaseAddress = new Uri("http://localhost:5733"))
    .AddStandardResilienceHandler(options =>
        // L'amont enregistre le paiement, puis met 3 s a repondre.
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(1));

var app = builder.Build();

app.MapPost("/commandes/{id:int}/paiement", async (int id, ClientPaiements paiements,
    CancellationToken ct) => TypedResults.Ok(await paiements.PayerAsync(id, 42.50m, ct)));

app.Run();

public sealed class ClientPaiements(HttpClient http)
{
    public async Task<HttpStatusCode> PayerAsync(int commande, decimal montant, CancellationToken ct)
    {
        using var reponse = await http.PostAsJsonAsync("/paiements", new { commande, montant }, ct);
        return reponse.StatusCode;
    }
}

// POST /commandes/7/paiement -> 500 apres 12 a 14 s selon la gigue (TimeoutRejectedException)
// L'amont, lui, a enregistre 4 paiements : la tentative initiale et 3 reessais.

Un paiement demandé, quatre enregistrés. La version juste ne réessaie que les méthodes sûres.

using System.Net;
using Microsoft.Extensions.Http.Resilience;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<ClientPaiements>(client =>
        client.BaseAddress = new Uri("http://localhost:5733"))
    .AddStandardResilienceHandler(options =>
    {
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(1);

        // Pas de reessai pour POST, PUT, PATCH, DELETE et CONNECT.
        options.Retry.DisableForUnsafeHttpMethods();
    });

var app = builder.Build();

app.MapPost("/commandes/{id:int}/paiement", async (int id, ClientPaiements paiements,
    CancellationToken ct) => TypedResults.Ok(await paiements.PayerAsync(id, 42.50m, ct)));

app.Run();

public sealed class ClientPaiements(HttpClient http)
{
    public async Task<HttpStatusCode> PayerAsync(int commande, decimal montant, CancellationToken ct)
    {
        using var reponse = await http.PostAsJsonAsync("/paiements", new { commande, montant }, ct);
        return reponse.StatusCode;
    }
}

// POST /commandes/7/paiement -> 500 apres 1 a 2 s : une seule tentative, coupee au bout
// d'1 s (TimeoutRejectedException)
// L'amont a enregistre 1 paiement.

Le doublon a disparu, pas l'incertitude : l'appelant ne sait pas si le paiement a eu lieu. Rendre un POST réessayable demande la coopération du service appelé, par exemple une clé d'idempotence qu'il reconnaît d'un envoi à l'autre.

Pour composer ses propres stratégies, AddResilienceHandler donne le constructeur du pipeline ; la documentation recommande de ne poser qu'un gestionnaire de résilience par client. Le disjoncteur y joue le rôle qui compte quand l'autre service est vraiment tombé : fermé, il laisse passer et compte les échecs ; ouvert, il refuse tout appel sans le tenter ; au bout de BreakDuration, il laisse passer un essai et se referme s'il réussit.

using Microsoft.Extensions.Http.Resilience;
using Polly;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<ClientMeteo>(client =>
        client.BaseAddress = new Uri("http://localhost:5733"))
    .AddResilienceHandler("meteo", pipeline =>
    {
        // L'ordre d'ajout est l'ordre d'enveloppement, de l'exterieur vers l'interieur.
        pipeline.AddTimeout(TimeSpan.FromSeconds(10)); // budget total, reessais compris

        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 2,
            Delay = TimeSpan.FromMilliseconds(200),
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
        });

        pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,                          // la moitie d'echecs...
            MinimumThroughput = 5,                       // ...sur au moins 5 appels...
            SamplingDuration = TimeSpan.FromSeconds(30), // ...dans les 30 dernieres secondes
            BreakDuration = TimeSpan.FromSeconds(15),    // puis 15 s ouvert, et un essai
        });

        pipeline.AddTimeout(TimeSpan.FromSeconds(2)); // par tentative
    });

var app = builder.Build();

app.MapGet("/meteo/{ville}", (string ville, ClientMeteo meteo, CancellationToken ct) =>
    meteo.LireAsync(ville, ct));

app.Run();

public sealed class ClientMeteo(HttpClient http)
{
    public Task<string> LireAsync(string ville, CancellationToken ct) =>
        http.GetStringAsync($"/meteo/{Uri.EscapeDataString(ville)}", ct);
}

// L'amont repond 500 a tout. Quatre GET /meteo/Lyon a la suite, en Production
// (une exception non traitee devient un 500) :
//   1er : 3 tentatives, HttpRequestException                     2,2 s    amont : 3 appels
//   2e  : 2 tentatives, le circuit s'ouvre, BrokenCircuitException 0,5 s  amont : 5
//   3e  : BrokenCircuitException, sans appel                    17 ms    amont : 5
//   4e  : BrokenCircuitException, sans appel                     9 ms    amont : 5

Le réessai ne traite pas BrokenCircuitException : une fois le circuit ouvert, l'appelant échoue en quelques millisecondes et l'amont n'est plus sollicité, ce qui lui laisse le temps de revenir au lieu de crouler sous les réessais de tous ses clients.

Health checks : vivant ou prêt

Un orchestrateur pose deux questions différentes. La sonde de vie demande si le processus fonctionne encore ; s'il ne répond pas, l'orchestrateur le redémarre. La sonde de disponibilité demande s'il peut servir maintenant ; sinon, il le retire du répartiteur de charge, sans le redémarrer. La faute classique est de répondre aux deux avec le même contrôle.

// Base et ControleBase : ceux de l'exemple suivant.
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<Base>();
builder.Services.AddHealthChecks().AddCheck<ControleBase>("base");

var app = builder.Build();

// Une seule URL, qui interroge la base, declaree a l'orchestrateur comme
// sonde de vie (livenessProbe) et comme sonde de disponibilite (readinessProbe).
app.MapHealthChecks("/sante");

app.Run();

La base tombe : toutes les instances échouent à la sonde de vie, et l'orchestrateur les redémarre toutes. Cela ne répare pas la base, vide les caches, et à son retour la base reçoit d'un coup le préchauffage de toutes les instances. La sonde de vie ne doit dépendre de rien d'extérieur ; les dépendances vont dans la sonde de disponibilité, avec l'état du démarrage.

using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<Base>();
builder.Services.AddSingleton<EtatDemarrage>();
builder.Services.AddHostedService<Prechauffage>();

builder.Services.AddHealthChecks()
    // Pret quand le prechauffage est fini et que la base repond en moins de 2 s.
    .AddCheck<ControleDemarrage>("demarrage", tags: ["pret"])
    .AddCheck<ControleBase>("base", tags: ["pret"], timeout: TimeSpan.FromSeconds(2));

var app = builder.Build();

// Vivant : le processus repond. Aucun controle, donc aucune dependance.
app.MapHealthChecks("/sante/vivant", new HealthCheckOptions { Predicate = _ => false });

// Pret : il peut servir. Seuls les controles etiquetes « pret ».
app.MapHealthChecks("/sante/pret", new HealthCheckOptions
{
    Predicate = controle => controle.Tags.Contains("pret"),
});

// Pour l'exemple : faire tomber la base (panne), la ralentir (lente), la retablir (ok).
app.MapPost("/demo/base/{etat}", (string etat, Base b) => { b.Etat = etat; });

app.Run();

// Tient lieu d'une vraie base de donnees.
public sealed class Base
{
    public volatile string Etat = "ok";

    public async Task<bool> RepondAsync(CancellationToken ct)
    {
        if (Etat == "lente") await Task.Delay(TimeSpan.FromSeconds(30), ct);
        return Etat != "panne";
    }
}

public sealed class EtatDemarrage
{
    public volatile bool Termine;
}

public sealed class Prechauffage(EtatDemarrage etat) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken arret)
    {
        await Task.Delay(TimeSpan.FromSeconds(5), arret); // charger un cache, migrer...
        etat.Termine = true;
    }
}

public sealed class ControleDemarrage(EtatDemarrage etat) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext contexte, CancellationToken ct) =>
        Task.FromResult(etat.Termine
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Unhealthy("prechauffage en cours"));
}

public sealed class ControleBase(Base b) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext contexte, CancellationToken ct) =>
        await b.RepondAsync(ct)
            ? HealthCheckResult.Healthy()
            : new HealthCheckResult(contexte.Registration.FailureStatus, "la base ne repond pas");
}
# Pendant les 5 premieres secondes (Date, Server et Transfer-Encoding omis) :
curl -i $API/sante/pret
# HTTP/1.1 503 Service Unavailable
# Content-Type: text/plain
# Cache-Control: no-store, no-cache
# Expires: Thu, 01 Jan 1970 00:00:00 GMT
# Pragma: no-cache
#
# Unhealthy
curl -i $API/sante/vivant
# HTTP/1.1 200 OK
# Healthy

# Le detail n'est que dans le journal :
# fail: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103]
#       Health check demarrage with status Unhealthy completed after 4.1667ms
#       with message 'prechauffage en cours'

# Prechauffage fini : pret -> 200 Healthy
# POST /demo/base/panne : pret -> 503 Unhealthy, vivant -> 200 Healthy
# POST /demo/base/lente : pret -> 503 Unhealthy au bout de 2,1 s
# POST /demo/base/ok    : pret -> 200 Healthy

Par défaut, Healthy et Degraded répondent 200, Unhealthy répond 503, et le corps ne contient que l'état global, ce qui évite d'exposer l'architecture interne à qui appelle l'URL ; un ResponseWriter permet d'en dire plus. Un contrôle n'a pas de délai par défaut : sans le paramètre timeout, la base lente aurait tenu la sonde trente secondes, bien au-delà de ce qu'attend l'orchestrateur.

Travail en arrière-plan et arrêt propre

Un IHostedService reçoit StartAsync au démarrage de l'hôte et StopAsync à son arrêt. BackgroundService en simplifie l'écriture : ExecuteAsync tourne toute la vie du service, et son jeton est signalé à l'arrêt. Depuis .NET 10, ExecuteAsync s'exécute entièrement en arrière-plan, partie synchrone comprise : un Thread.Sleep de trois secondes placé avant le premier await ne retarde plus le démarrage de Kestrel. Un service hébergé est un singleton, sans portée : pourquoi il en ouvre une par IServiceScopeFactory pour chaque unité de travail est expliqué dans la section « Résoudre depuis un singleton » du cours « Injection de dépendances ». Ce qui suit porte sur l'arrêt.

À l'arrêt — Ctrl+C, ou le SIGTERM d'un orchestrateur — l'hôte signale le jeton puis attend la fin d'ExecuteAsync, au plus HostOptions.ShutdownTimeout, 30 secondes par défaut. Le service suivant ignore ce jeton.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<ServiceRelances>();
builder.Services.AddHostedService<TacheRelances>();

var app = builder.Build();
app.Run();

public sealed class TacheRelances(IServiceScopeFactory portees) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken arret)
    {
        // Le jeton d'arret n'est lu nulle part.
        while (true)
        {
            await using (var portee = portees.CreateAsyncScope())
            {
                var service = portee.ServiceProvider.GetRequiredService<ServiceRelances>();
                await service.EnvoyerLotAsync(CancellationToken.None);
            }
            await Task.Delay(TimeSpan.FromSeconds(60));
        }
    }
}

// Scoped : il pourrait dependre d'un DbContext.
public sealed class ServiceRelances(ILogger<ServiceRelances> journal)
{
    public async Task EnvoyerLotAsync(CancellationToken ct)
    {
        journal.LogInformation("lot de relances : debut");
        await Task.Delay(TimeSpan.FromSeconds(2), ct);
        journal.LogInformation("lot de relances : fin");
    }
}

// Arret demande une seconde apres le debut d'un lot :
//   info: ServiceRelances[0]  lot de relances : debut
//   info: Microsoft.Hosting.Lifetime[0]  Application is shutting down...
//   info: ServiceRelances[0]  lot de relances : fin
//   ... puis rien : le processus se termine une trentaine de secondes apres la demande.

L'arrêt attend trente secondes une boucle qui ne regarde jamais le jeton, et un lot plus long que ce délai serait abandonné en plein milieu. Sous Kubernetes, terminationGracePeriodSeconds vaut aussi 30 secondes par défaut : le processus risque d'être tué avant que l'hôte ait fini. La version juste fait suivre le jeton jusqu'au travail et jusqu'à l'attente.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<ServiceRelances>(); // celui de l'exemple precedent
builder.Services.AddHostedService<TacheRelances>();

var app = builder.Build();
app.Run();

public sealed class TacheRelances(IServiceScopeFactory portees, ILogger<TacheRelances> journal)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken arret)
    {
        using var minuteur = new PeriodicTimer(TimeSpan.FromSeconds(60));
        do
        {
            try
            {
                // Une portee par lot : les services scoped vivent le temps du lot.
                await using var portee = portees.CreateAsyncScope();
                var service = portee.ServiceProvider.GetRequiredService<ServiceRelances>();
                await service.EnvoyerLotAsync(arret);
            }
            // Le filtre porte sur le jeton, pas sur le type : un HttpClient.Timeout ou
            // une commande SQL expiree levent aussi une OperationCanceledException,
            // qui ne signifie pas que l'hote s'arrete.
            catch (Exception ex) when (!arret.IsCancellationRequested)
            {
                // Une exception qui sortirait d'ExecuteAsync arreterait toute l'application.
                journal.LogError(ex, "Lot de relances en echec, nouvel essai au prochain tour");
            }
        }
        // Rend false... ou leve OperationCanceledException des que l'arret est demande.
        while (await minuteur.WaitForNextTickAsync(arret));
    }
}

// Meme arret, une seconde apres le debut d'un lot :
//   info: ServiceRelances[0]  lot de relances : debut
//   info: Microsoft.Hosting.Lifetime[0]  Application is shutting down...
//   ... le processus se termine moins d'une demi-seconde apres la demande ;
//   le lot, interrompu a son await, n'ecrit pas sa ligne de fin.

Le lot en cours s'arrête à son prochain await. Si un lot ne doit pas être coupé, c'est au code de choisir où il consulte le jeton, par exemple entre deux relances plutôt qu'au milieu d'une. Le try n'est pas décoratif. Une exception qui sort d'ExecuteAsync arrête tout l'hôte (BackgroundServiceExceptionBehavior.StopHost, le défaut depuis .NET 6) : l'API tombe avec sa tâche de fond. Son filtre teste le jeton et non le type de l'exception, parce qu'un HttpClient.Timeout dépassé ou une commande SQL expirée lèvent aussi une OperationCanceledException : exclure ce type laisserait un simple délai d'attente arrêter l'application. En .NET 10, le processus se termine alors avec le code de sortie 0 : un superviseur qui ne relance qu'en cas d'échec (Restart=on-failure de systemd, --restart on-failure de Docker, un Job Kubernetes) le prend pour un arrêt normal. Cela changera avec .NET 11, où l'arrêt renvoie l'exception depuis la Preview 3. Quand le travail vient des requêtes plutôt que d'un minuteur, un Channel<T> borné, écrit par les gestionnaires et lu par le service hébergé, en fait une file ; Channel<T> et le modèle d'annulation sont décrits dans « Parallélisme et flux ».

Ce cours vous a servi ? Offrir un café Signaler une erreur