Microsoft.Extensions.Resilience : pipelines de résilience intégrés à .NET

Ce package fournit un modèle standardisé pour définir, composer et observer des stratégies de résilience (basées sur Polly) directement via l'injection de dépendances .NET.

L'objectif est de réduire le code infrastructure répétitif et d'offrir une configuration centralisée, observable et testable pour vos appels externes et internes.

Installation

# Paquets principaux
dotnet add package Microsoft.Extensions.Resilience

# Intégration HttpClient
dotnet add package Microsoft.Extensions.Http.Resilience

Le premier paquet expose le modèle de pipeline; le second ajoute des helpers spécifiques à HttpClientFactory (AddResilienceHandler).

Concepts clés

Un pipeline est une séquence ordonnée de stratégies. On l'enregistre avec AddResiliencePipeline<TContext>(). On l'exécute via IResiliencePipelineProvider. Chaque stratégie (retry, timeout, circuit breaker, hedging...) est modulaire.

// Program.cs (ou dans un module d'enregistrement de services)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Resilience;
using Polly; // (stratégies sous-jacentes)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddResiliencePipeline<string>(
    pipelineName: "standard-pipeline",
    configure: (pipelineBuilder, context) =>
    {
        // 1. Retry exponentiel
        pipelineBuilder.AddRetry(new RetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromMilliseconds(200),
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
            ShouldHandle = new PredicateBuilder().Handle<Exception>()
        });

        // 2. Timeout global
        pipelineBuilder.AddTimeout(TimeSpan.FromSeconds(5));

        // 3. Circuit breaker
        pipelineBuilder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(30),
            MinimumThroughput = 10,
            BreakDuration = TimeSpan.FromSeconds(15)
        });
    });

var app = builder.Build();

app.MapGet("/work", async (IResiliencePipelineProvider<string> provider) =>
{
    var pipeline = provider.GetPipeline("standard-pipeline");
    return await pipeline.ExecuteAsync(async token =>
    {
        // Opération potentiellement fragile
        await Task.Delay(100, token);
        return Results.Ok("OK");
    }, context: "partition-A"); // partition optionnelle
});

app.Run();

Stratégies intégrées

StratégieQuand l'utiliser ?Exemple
RetryErreurs transitoires (5xx, IOException)AddRetry(...)
TimeoutLimiter la durée totale d'une opérationAddTimeout(5s)
CircuitBreakerEmpêcher l'avalanche quand la cible tombeAddCircuitBreaker(...)
HedgingRéduire la latence p95/p99 via appels parallèles contrôlésAddHedging(...)
FallbackFournir une réponse de secoursAddFallback(...)
RateLimiter / BulkheadLimiter concurrence ou débitAddConcurrencyLimiter(...)
ChaosTests de robustesse (fault injection)AddChaosLatency(...)
Intégration HttpClient

AddResilienceHandler enchaîne les stratégies côté pipeline HTTP. Les stratégies s'appliquent avant l'envoi effectif (SocketsHttpHandler).

builder.Services.AddHttpClient("catalog")
    .AddResilienceHandler("http-standard", (builder, context) =>
    {
        builder.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromMilliseconds(250),
            BackoffType = DelayBackoffType.Exponential,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => (int)r.StatusCode >= 500)
                .Handle<HttpRequestException>()
        });
        builder.AddTimeout(TimeSpan.FromSeconds(4));
        builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
        {
            FailureRatio = 0.2,
            SamplingDuration = TimeSpan.FromSeconds(20),
            MinimumThroughput = 20,
            BreakDuration = TimeSpan.FromSeconds(10)
        });
    });

// Utilisation via IHttpClientFactory
app.MapGet("/products", async (IHttpClientFactory factory) =>
{
    var client = factory.CreateClient("catalog");
    var response = await client.GetAsync("https://example.com/api/products");
    return Results.Text($"Status: {response.StatusCode}");
});
Hedging (latence optimisée)

La stratégie lance des requêtes alternatives si la première tarde ou échoue selon un prédicat. À manier avec parcimonie pour ne pas surcharger la cible.

builder.Services.AddResiliencePipeline<HttpResponseMessage>(
  "search-hedged",
  (pb, ctx) =>
  {
      pb.AddHedging(new HedgingStrategyOptions<HttpResponseMessage>
      {
          MaxHedgedAttempts = 3,
          Delay = TimeSpan.FromMilliseconds(150),
          ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
              .HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == System.Net.HttpStatusCode.RequestTimeout),
          OnHedging = args =>
          {
              Console.WriteLine($"Hedge attempt #{args.AttemptNumber}");
              return default;
          }
      });
      pb.AddTimeout(TimeSpan.FromSeconds(2));
  });

app.MapGet("/search", async (IResiliencePipelineProvider<HttpResponseMessage> provider) =>
{
    var pipeline = provider.GetPipeline("search-hedged");
    var result = await pipeline.ExecuteAsync(async token =>
    {
        using var http = new HttpClient();
        return await http.GetAsync("https://example.com/api/search?q=test", token);
    });
    return Results.Text(result.StatusCode.ToString());
});
Partitionnement (context key)

Vous pouvez passer un 'context' lors de ExecuteAsync pour différencier instrumentation, quotas ou limites par client/locataire.

builder.Services.AddResiliencePipeline<string>("per-customer", (pb, ctx) =>
{
    pb.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 2, Delay = TimeSpan.FromMilliseconds(100) });
});

app.MapGet("/customer/{id}", async (string id, IResiliencePipelineProvider<string> provider) =>
{
    var pipeline = provider.GetPipeline("per-customer");
    return await pipeline.ExecuteAsync(async token =>
    {
        // Logique dépendant du client
        await Task.Delay(50, token);
        return Results.Ok(new { Customer = id });
    }, context: id); // le context devient la clé de partition
});
Un autre exemple concret sans HTTP

Création d'un pipeline autonome dans une classe utilitaire, puis utilisation dans un repository pour entourer un appel SQL. Remplacez ADO.NET par EF Core ou Dapper si vous préférez.

using System.Data;
using Microsoft.Data.SqlClient;
using Polly;

public sealed class DatabaseResilience
{
  private readonly ResiliencePipeline _pipeline;

  public DatabaseResilience()
  {
    _pipeline = new ResiliencePipelineBuilder()
      .AddRetry(new RetryStrategyOptions
      {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromMilliseconds(200),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        ShouldHandle = new PredicateBuilder()
          .Handle<SqlException>()
          .Handle<TimeoutException>()
      })
      .AddTimeout(TimeSpan.FromSeconds(3))
      .AddCircuitBreaker(new CircuitBreakerStrategyOptions
      {
        FailureRatio = 0.3,
        SamplingDuration = TimeSpan.FromSeconds(20),
        MinimumThroughput = 10,
        BreakDuration = TimeSpan.FromSeconds(10)
      })
      .Build();
  }

  public async Task<T?> ExecuteAsync<T>(Func<CancellationToken, Task<T?>> dbCall, CancellationToken ct = default)
  {
    var context = ResilienceContextPool.Shared.Get(ct);
    try
    {
      return await _pipeline.ExecuteAsync(async (ctx) =>
      {
        var result = await dbCall(ctx.CancellationToken);
        return result;
      }, context);
    }
    finally
    {
      ResilienceContextPool.Shared.Return(context);
    }
  }
}

// Usage dans un repository "classique"
public sealed class ProductRepository
{
  private readonly string _connString;
  private readonly DatabaseResilience _resilience = new();

  public ProductRepository(string connectionString)
    => _connString = connectionString;

  public Task<string?> GetNameAsync(int id, CancellationToken ct = default)
    => _resilience.ExecuteAsync<string?>(async token =>
    {
      await using var conn = new SqlConnection(_connString);
      await conn.OpenAsync(token);
      await using var cmd = conn.CreateCommand();
      cmd.CommandText = "SELECT TOP(1) Name FROM Products WHERE Id = @id";
      var p = cmd.CreateParameter();
      p.ParameterName = "@id"; p.Value = id; cmd.Parameters.Add(p);
      var result = await cmd.ExecuteScalarAsync(token);
      return result as string;
    }, ct);
}
// Remarque: vous pouvez remplacer ADO.NET par Dapper/EF Core; l'important est que l'appel
// DB soit exécuté dans la lambda du pipeline.
Observabilité

Les pipelines exposent des événements (logs, métriques) exploitables par OpenTelemetry. Chaque tentative, ouverture/fermeture de circuit, timeout ou hedging peut être tracé.

// Activer la télémétrie (ex: OpenTelemetry + logs)
builder.Services.AddLogging();
builder.Services.AddOpenTelemetry().WithMetrics(m => { /* ... */ }).WithTracing(t => { /* ... */ });
// Les events d'exécution des stratégies sont émis automatiquement (compteurs, logs...)

Configuration via appsettings

Les options peuvent être bindées depuis la configuration. Vous pouvez créer un binder custom ou un wrapper pour charger dynamiquement des options par environnement.

{
  "Resilience": {
    "Pipelines": {
      "standard-pipeline": {
        "Retry": { "MaxRetryAttempts": 3, "Delay": "00:00:00.200", "UseJitter": true },
        "Timeout": { "Timeout": "00:00:05" },
        "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDuration": "00:00:30", "BreakDuration": "00:00:15", "MinimumThroughput": 10 }
      }
    }
  }
}

Dans Program.cs : récupérez IConfiguration et appliquez les valeurs lors du build des pipelines.

Bonnes pratiques

  • Ne pas multiplier les retries empilés (éviter cascades).
  • Isoler les stratégies par type d'appel (lecture, écriture).
  • Définir des timeouts explicites (pas uniquement default).
  • Surveiller métriques (taux d'échec, ouvertures de circuits).
  • Limiter Hedging et Chaos aux environnements de test ou à quelques endpoints.
  • Utiliser partitioning pour différencier clients sensibles.
  • Documenter les paramètres (durées, seuils) dans le repo.

En résumé

Microsoft.Extensions.Resilience fournit une surcouche native .NET pour construire des pipelines de résilience cohérents, testables et observables.

Il standardise l'usage des stratégies Polly et simplifie la maintenance à grande échelle dans des architectures distribuées.

Écrit le 2025-09-06