En production, les appels réseau échouent : timeouts, erreurs 503, connexions refusées. Plutôt que de laisser ces erreurs transitoires remonter jusqu’à l’utilisateur, Polly permet de définir des stratégies de résilience — retry, circuit breaker, timeout, fallback — de manière déclarative. Depuis .NET 8, Polly v8 et Microsoft.Extensions.Http.Resilience s’intègrent nativement dans l’écosystème .NET.
Pourquoi la résilience ?
Le problème : les erreurs transitoires
Dans une architecture distribuée (microservices, APIs externes, bases de données), les erreurs transitoires sont inévitables :
- Un service redémarre → erreur 503 pendant 2 secondes.
- Le réseau est saturé → timeout.
- La base de données est surchargée → connexion refusée.
Sans stratégie de résilience, chaque erreur transitoire se propage en cascade :
// ❌ Sans résilience : une erreur réseau = une erreur utilisateur
public async Task<Client> GetClientAsync(int id)
{
var response = await httpClient.GetAsync($"/api/clients/{id}");
response.EnsureSuccessStatusCode(); // lève une exception si 5xx
return await response.Content.ReadFromJsonAsync<Client>();
}
La solution : des stratégies de résilience
// ✅ Avec Polly : retry automatique sur erreurs transitoires
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(500),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => r.StatusCode == HttpStatusCode.ServiceUnavailable)
})
.Build();
Installation
Polly v8 est distribué via deux packages principaux :
# Package de base (stratégies de résilience)
dotnet add package Polly.Core
# Intégration avec HttpClientFactory (recommandé pour les appels HTTP)
dotnet add package Microsoft.Extensions.Http.Resilience
# Intégration avec l'injection de dépendances
dotnet add package Microsoft.Extensions.Resilience
Microsoft.Extensions.Http.Resilienceest le package officiel de Microsoft qui intègre Polly v8 avecIHttpClientFactory. C’est le point d’entrée recommandé pour les appels HTTP.
Les stratégies de résilience
1. Retry — Réessayer automatiquement
La stratégie la plus courante : réessayer l’opération après un délai.
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3, // 3 tentatives max
Delay = TimeSpan.FromSeconds(1), // délai initial
BackoffType = DelayBackoffType.Exponential, // 1s, 2s, 4s
UseJitter = true, // ajoute un aléa pour éviter les "thundering herds"
OnRetry = args =>
{
Console.WriteLine($"Retry #{args.AttemptNumber} après {args.RetryDelay}");
return ValueTask.CompletedTask;
}
})
.Build();
// Utilisation
var result = await pipeline.ExecuteAsync(async ct =>
{
return await httpClient.GetStringAsync("https://api.example.com/data", ct);
});
Types de backoff
| Type | Délais (base = 1s) | Usage |
|---|---|---|
Constant |
1s, 1s, 1s | Délai fixe entre chaque retry |
Linear |
1s, 2s, 3s | Augmentation linéaire |
Exponential |
1s, 2s, 4s | Doublement du délai (le plus courant) |
Exponential + jitter |
~1.1s, ~2.3s, ~3.8s | Exponentiel avec variation aléatoire |
Le jitter (UseJitter = true) est important : sans lui, tous les clients réessaient exactement au même moment après une panne, ce qui peut surcharger le service au moment de la reprise (“thundering herd”).
2. Circuit Breaker — Couper le circuit
Le circuit breaker surveille le taux d’échec et coupe les appels quand le service distant est manifestement en panne. Cela évite de saturer un service déjà surchargé.
var pipeline = new ResiliencePipelineBuilder()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // 50% d'échecs déclenche l'ouverture
MinimumThroughput = 10, // au moins 10 appels avant d'évaluer
SamplingDuration = TimeSpan.FromSeconds(30), // fenêtre d'évaluation
BreakDuration = TimeSpan.FromSeconds(15), // durée du circuit ouvert
OnOpened = args =>
{
Console.WriteLine($"Circuit OUVERT pour {args.BreakDuration}");
return ValueTask.CompletedTask;
},
OnClosed = _ =>
{
Console.WriteLine("Circuit FERMÉ");
return ValueTask.CompletedTask;
}
})
.Build();
Les trois états du circuit
┌──────────────────┐
│ FERMÉ │ ← état normal, les appels passent
│ (Closed) │
└───────┬──────────┘
│ taux d'échec > seuil
▼
┌──────────────────┐
│ OUVERT │ ← tous les appels sont rejetés immédiatement
│ (Open) │ (BrokenCircuitException)
└───────┬──────────┘
│ après BreakDuration
▼
┌──────────────────┐
│ SEMI-OUVERT │ ← un seul appel-test est autorisé
│ (Half-Open) │
└───────┬──────────┘
│ succès → FERMÉ
│ échec → OUVERT
3. Timeout — Limiter le temps d’attente
var pipeline = new ResiliencePipelineBuilder()
.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(5),
OnTimeout = args =>
{
Console.WriteLine($"Timeout après {args.Timeout}");
return ValueTask.CompletedTask;
}
})
.Build();
Il existe deux usages de timeout :
- Timeout global (outer) : enveloppe tout le pipeline, y compris les retries.
- Timeout par tentative (inner) : limite chaque tentative individuelle.
var pipeline = new ResiliencePipelineBuilder()
// Timeout global : 30s pour l'ensemble (retries inclus)
.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(30)
})
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential
})
// Timeout par tentative : 5s max par appel individuel
.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(5)
})
.Build();
4. Fallback — Valeur de repli
Retourne une valeur par défaut quand l’opération échoue, au lieu de lever une exception.
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
{
FallbackAction = args =>
{
var fallbackResponse = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]") // liste vide comme fallback
};
return Outcome.FromResultAsValueTask(fallbackResponse);
},
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult(r => !r.IsSuccessStatusCode)
})
.Build();
5. Rate Limiter — Limiter le débit
Contrôle le nombre d’appels simultanés ou par seconde pour ne pas surcharger un service.
var pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter(new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = 100, // 100 appels max
Window = TimeSpan.FromMinutes(1), // par minute
SegmentsPerWindow = 6, // 6 segments de 10s
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10 // 10 appels en file d'attente max
}))
.Build();
Composer les stratégies
La puissance de Polly réside dans la composition. On combine les stratégies dans un pipeline, et l’ordre compte (l’exécution va du premier ajouté vers le dernier) :
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
// 1. Fallback (le plus externe) : valeur de repli si tout échoue
.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
{
FallbackAction = _ => Outcome.FromResultAsValueTask(
new HttpResponseMessage(HttpStatusCode.OK)
{ Content = new StringContent("{\"cache\": true}") }),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<Exception>()
})
// 2. Timeout global : 30s pour l'ensemble
.AddTimeout(TimeSpan.FromSeconds(30))
// 3. Retry : 3 tentatives avec backoff
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
})
// 4. Circuit breaker : coupe si trop d'échecs
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
})
// 5. Timeout par tentative : 5s max par appel
.AddTimeout(TimeSpan.FromSeconds(5))
.Build();
L’ordre d’exécution pour un appel :
Fallback → Timeout global (30s) → Retry → Circuit Breaker → Timeout (5s) → appel HTTP
Intégration avec IHttpClientFactory (recommandé)
La manière la plus idiomatique d’utiliser Polly en .NET est de l’intégrer avec IHttpClientFactory via Microsoft.Extensions.Http.Resilience.
Configuration dans Program.cs
builder.Services
.AddHttpClient("CatalogueApi", client =>
{
client.BaseAddress = new Uri("https://api.catalogue.com");
})
.AddStandardResilienceHandler(); // pipeline de résilience standard
AddStandardResilienceHandler() configure automatiquement un pipeline complet :
| Stratégie | Configuration par défaut |
|---|---|
| Rate limiter | 1000 requêtes simultanées max |
| Timeout global | 30 secondes |
| Retry | 3 tentatives, backoff exponentiel + jitter |
| Circuit breaker | Coupe à 10% d’échecs sur 30s, pause de 5s |
| Timeout par tentative | 10 secondes |
Personnaliser le pipeline standard
builder.Services
.AddHttpClient("CatalogueApi", client =>
{
client.BaseAddress = new Uri("https://api.catalogue.com");
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 5;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
});
Utilisation dans un service
public class CatalogueService
{
private readonly HttpClient _httpClient;
public CatalogueService(IHttpClientFactory factory)
{
_httpClient = factory.CreateClient("CatalogueApi");
}
public async Task<List<Produit>> GetProduitsAsync(CancellationToken ct = default)
{
// Les stratégies de résilience sont appliquées automatiquement
var response = await _httpClient.GetAsync("/api/produits", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<Produit>>(ct) ?? [];
}
}
Avec un client typé
// Enregistrement
builder.Services
.AddHttpClient<CatalogueService>(client =>
{
client.BaseAddress = new Uri("https://api.catalogue.com");
})
.AddStandardResilienceHandler();
// Le service reçoit directement un HttpClient configuré
public class CatalogueService(HttpClient httpClient)
{
public async Task<List<Produit>> GetProduitsAsync(CancellationToken ct = default)
{
return await httpClient.GetFromJsonAsync<List<Produit>>("/api/produits", ct) ?? [];
}
}
Intégration avec l’injection de dépendances
Pour des opérations non-HTTP (base de données, file de messages, etc.), on utilise Microsoft.Extensions.Resilience :
// Enregistrement d'un pipeline nommé
builder.Services.AddResiliencePipeline("database", builder =>
{
builder
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder()
.Handle<SqlException>(ex => ex.IsTransient)
})
.AddTimeout(TimeSpan.FromSeconds(10));
});
// Utilisation via injection
public class CommandeRepository(ResiliencePipelineProvider<string> pipelineProvider)
{
public async Task<Commande?> GetByIdAsync(int id, CancellationToken ct = default)
{
var pipeline = pipelineProvider.GetPipeline("database");
return await pipeline.ExecuteAsync(async token =>
{
return await dbContext.Commandes.FindAsync([id], token);
}, ct);
}
}
Observabilité
Polly v8 s’intègre nativement avec les métriques .NET et OpenTelemetry :
// Les métriques sont émises automatiquement
// Compteurs disponibles :
// - polly.strategy.attempt.duration (durée de chaque tentative)
// - polly.strategy.pipeline.duration (durée totale du pipeline)
// - polly.strategy.attempt.count (nombre de tentatives)
// Pour activer les métriques dans OpenTelemetry :
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("Polly"); // active les métriques Polly
});
Bonnes pratiques
1. Toujours utiliser un jitter sur les retries
// ❌ Sans jitter : tous les clients réessaient au même instant
.AddRetry(new RetryStrategyOptions
{
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Exponential,
UseJitter = false // ← thundering herd
})
// ✅ Avec jitter : les retries sont désynchronisés
.AddRetry(new RetryStrategyOptions
{
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true // ← les délais varient aléatoirement
})
2. Ne pas retrier les erreurs non transitoires
// ❌ Retrier une erreur 400 Bad Request n'a aucun sens
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => !r.IsSuccessStatusCode) // ← inclut les 400, 401, 404...
})
// ✅ Retrier uniquement les erreurs transitoires (5xx, timeout)
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
.HandleResult(r => r.StatusCode == HttpStatusCode.RequestTimeout)
.HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
})
3. Propager le CancellationToken
// ✅ Toujours passer le CancellationToken à travers le pipeline
var result = await pipeline.ExecuteAsync(
async ct => await httpClient.GetAsync(url, ct),
cancellationToken // ← permet l'annulation depuis l'appelant
);
4. Préférer AddStandardResilienceHandler pour les appels HTTP
Le pipeline standard couvre la majorité des cas d’usage. Ne créer un pipeline personnalisé que si les valeurs par défaut ne conviennent pas.
5. Combiner retry et circuit breaker
Le retry seul ne suffit pas : si le service est complètement en panne, les retries saturent la file d’attente. Le circuit breaker coupe les appels immédiatement.
// ✅ Le circuit breaker protège le service en panne
// Le retry gère les erreurs ponctuelles
.AddRetry(...)
.AddCircuitBreaker(...)
Résumé
| Stratégie | Quand l’utiliser | Ce qu’elle fait |
|---|---|---|
| Retry | Erreurs transitoires ponctuelles | Réessaie l’opération après un délai |
| Circuit Breaker | Service en panne prolongée | Coupe les appels pour protéger le service |
| Timeout | Appels qui traînent | Limite le temps d’attente |
| Fallback | Dégradation gracieuse | Retourne une valeur par défaut |
| Rate Limiter | Protection du service distant | Limite le débit d’appels |
| Composition | Cas réel | Combine les stratégies dans un pipeline |
AddStandardResilienceHandler |
Appels HTTP standard | Pipeline complet prêt à l’emploi |