Cos'e' Polly e come si usa per rendere resilienti le applicazioni .NET?
Polly e' la libreria standard de facto per la resilienza in .NET: implementa retry, circuit breaker, timeout, bulkhead e fallback con un'API fluente e una profonda integrazione con ASP.NET Core e HttpClientFactory.
Con Polly v8 si costruisce una Resilience Pipeline dichiarativa che avvolge le operazioni rischiose. La pipeline gestisce automaticamente i fallimenti transitori senza inquinare il codice applicativo.
- Retry: riprova automaticamente con backoff esponenziale e jitter
- Circuit Breaker: blocca le chiamate verso servizi in stato di guasto
- Timeout: impone limiti espliciti alle operazioni lente
- Fallback: restituisce un valore di default quando tutto il resto fallisce

Questa guida fa parte della sezione completa sul C# e sviluppo software moderno con .NET.
Ogni applicazione .NET che chiama servizi esterni si scontra prima o poi con la realta' della rete: latenze variabili, timeout, rate limit, downtime momentanei. Non e' una questione di se accade, ma di quando. E quando accade in produzione, la differenza tra un sistema che crasha e un sistema che degrada elegantemente e' spesso misurata in centinaia di migliaia di euro di mancato fatturato.
Il codice senza gestione della resilienza risponde a questi eventi in modo binario: funziona o va in eccezione. Il codice resiliente risponde in modo intelligente: riprova dove ha senso, si ferma dove non ha senso, torna a uno stato sicuro quando il servizio non e' disponibile. La differenza non e' solo tecnica, e' architetturale.
Polly e' la libreria che ha standardizzato questo approccio in .NET per oltre un decennio. Con Polly v8 completamente integrata nell'ecosistema .NET moderno tramite Microsoft.Extensions.Http.Resilience, non c'e' piu' nessun motivo per gestire la resilienza a mano. Questa guida ti mostra come usarla sul serio: con codice reale, scenari reali, e le trappole che solo chi l'ha usata in produzione conosce.
Se stai costruendo microservizi, applicazioni che chiamano API esterne, agenti AI che dipendono da provider LLM o qualsiasi sistema distribuito, questa guida ti serve. Gli esempi di codice mostrano Polly v8 con .NET 8 e 9, ma i concetti si applicano anche a progetti che migrano da versioni precedenti.
Cos'e' Polly e perche' ogni applicazione .NET in produzione ne ha bisogno
Polly e' una libreria open source per .NET che implementa i pattern di resilienza piu' consolidati dell'ingegneria del software: retry, circuit breaker, timeout, bulkhead isolation, rate limiter e fallback. Nasce nel 2013 da Michael Wolfenden e oggi e' il progetto .NET Foundation con piu' download mensili su NuGet dopo i pacchetti Microsoft stessi.
La domanda "perche' ne ho bisogno?" ha una risposta semplice: perche' qualsiasi chiamata a un sistema esterno puo' fallire, e il modo in cui gestisci quel fallimento determina la qualita' percepita del tuo sistema. Un'applicazione che smette di funzionare quando il database e' lento per 30 secondi e' un'applicazione di scarsa qualita', indipendentemente da quanto sia elegante il suo codice interno.
I fallimenti che Polly gestisce rientrano in tre categorie:
- Errori transitori: timeout di rete, connessioni rifiutate, 503 temporanei. Tipicamente si risolvono da soli nel giro di secondi o pochi tentativi.
- Servizi in sovraccarico: rate limit (429), latenze elevate, risposte degradate. Qui bisogna ridurre il traffico, non aumentarlo con i retry.
- Fallimenti prolungati: servizi down, dipendenze non disponibili per minuti o ore. Il sistema deve degradare elegantemente senza impattare le funzionalita' non dipendenti dal servizio fallito.
Polly non previene i fallimenti, li gestisce. La differenza e' che un sistema che gestisce i fallimenti rimane operativo anche quando le sue dipendenze non lo sono.
Nel contesto del mercato italiano, questo si traduce in termini concreti: un e-commerce che continua a servire pagine di prodotto anche quando il gateway di pagamento e' temporaneamente irraggiungibile, un gestionale che mantiene operativa la logistica anche quando il provider ERP e' in manutenzione, un chatbot AI che risponde con un messaggio di fallback ragionevole invece di esplodere quando l'API OpenAI e' sotto carico.
Polly v8 e la Resilience Pipeline: la nuova API rispetto alle versioni precedenti
Chi ha usato Polly nelle versioni v5, v6 o v7 conosce l'approccio basato su Policy.Handle<Exception>().WaitAndRetryAsync() con il pattern di wrapping esplicito. Funzionava, ma aveva alcuni limiti: la composizione delle policy era verbosa, l'integrazione con il DI container era manuale, e la telemetria richiedeva configurazione aggiuntiva.
Polly v8 riscrive completamente l'API introducendo il concetto di Resilience Pipeline come costrutto centrale. Una pipeline e' una sequenza ordinata di strategie di resilienza che avvolge un'operazione. Ogni strategia nella pipeline intercetta l'operazione, applica la propria logica, e passa il controllo alla strategia successiva.
Per iniziare, aggiungi i pacchetti necessari:
<!-- Per HTTP client con HttpClientFactory (raccomandato per ASP.NET Core) --> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" /> <!-- Per scenari avanzati o operazioni non-HTTP --> <PackageReference Include="Polly.Extensions" Version="8.10.0" /> <PackageReference Include="Polly.Core" Version="8.10.0" />
La differenza fondamentale nell'API v8 rispetto a v7:
// VECCHIO APPROCCIO (Polly v7) var retryPolicy = Policy .Handle<HttpRequestException>() .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); var circuitBreakerPolicy = Policy .Handle<HttpRequestException>() .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)); // Il wrapping era esplicito e l'ordine era invertito rispetto all'intuizione var policy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); var result = await policy.ExecuteAsync(() => _httpClient.GetStringAsync(url)); // NUOVO APPROCCIO (Polly v8) var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>() .AddRetry(new RetryStrategyOptions<HttpResponseMessage> { MaxRetryAttempts = 3 }) .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage> { FailureRatio = 0.5 }) .AddTimeout(TimeSpan.FromSeconds(10)) .Build(); var result = await pipeline.ExecuteAsync( async ct => await _httpClient.GetAsync(url, ct), cancellationToken);
Con la nuova API, l'ordine di aggiunta delle strategie nella pipeline corrisponde all'ordine in cui vengono attraversate dall'esterno verso l'interno. Questo e' piu' intuitivo e riduce gli errori di configurazione.
La vera svolta pero' e' l'integrazione con il DI container di ASP.NET Core tramite AddResilienceHandler, che vedremo in dettaglio nella sezione dedicata a HttpClientFactory.
Retry policy: come configurare i tentativi di ripetizione in modo intelligente
Il retry e' il pattern piu' semplice e quello piu' frequentemente mal configurato. La logica di base e' ovvia: se un'operazione fallisce, riprova. Il diavolo sta nei dettagli: quante volte, con quale intervallo, per quali tipi di errore, e cosa fare quando anche il retry finale fallisce.
Backoff esponenziale e jitter
Il primo errore tipico e' usare un intervallo fisso tra i retry. Con un intervallo fisso, se 100 client falliscono contemporaneamente (ad esempio dopo un breve downtime del server), riprovano tutti esattamente nello stesso momento, generando un picco di traffico che puo' essere peggiore del problema originale. Questo fenomeno si chiama "thundering herd".
La soluzione corretta combina backoff esponenziale (aumenta il delay ad ogni tentativo) con jitter (aggiunge variazione casuale):
builder.Services.AddHttpClient<IMioServizioClient, MioServizioClient>() .AddResilienceHandler("mio-servizio-retry", pipeline => { pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 4, Delay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(30), // Cap per evitare attese eccessive BackoffType = DelayBackoffType.Exponential, // 2s, 4s, 8s, 16s (con jitter) UseJitter = true, // Aggiunge variazione casuale (+/- 25% del delay) // Specifica esattamente quali errori giustificano un retry ShouldHandle = args => args.Outcome switch { { Exception: HttpRequestException } => PredicateResult.True(), { Result.StatusCode: HttpStatusCode.TooManyRequests } => PredicateResult.True(), { Result.StatusCode: HttpStatusCode.ServiceUnavailable } => PredicateResult.True(), { Result.StatusCode: HttpStatusCode.BadGateway } => PredicateResult.True(), { Result.StatusCode: HttpStatusCode.GatewayTimeout } => PredicateResult.True(), // NON riprovare per errori client-side (4xx diversi da 429) _ => PredicateResult.False() }, // Callback per logging del retry OnRetry = args => { _logger.LogWarning( "Retry {AttemptNumber} per {OperationKey}. Delay: {Delay}ms. Motivo: {Outcome}", args.AttemptNumber, args.Context.OperationKey, args.RetryDelay.TotalMilliseconds, args.Outcome.Exception?.Message ?? args.Outcome.Result?.StatusCode.ToString()); return ValueTask.CompletedTask; } }); });
Retry con Retry-After header
Quando chiami API con rate limiting, molti provider inviano un header Retry-After che indica esattamente quando puoi riprovare. Ignorarlo e' controproducente: stai consumando rate limit inutilmente e rischi un ban temporaneo.
pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, DelayGenerator = args => { // Controlla l'header Retry-After nella risposta if (args.Outcome.Result?.Headers.RetryAfter is RetryConditionHeaderValue retryAfter) { TimeSpan delay = retryAfter.Delta ?? (retryAfter.Date.HasValue ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.Zero); // Aggiungi un piccolo buffer per sicurezza return new ValueTask<TimeSpan?>(delay + TimeSpan.FromMilliseconds(100)); } // Fallback a backoff esponenziale con jitter return new ValueTask<TimeSpan?>( TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber)) + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500))); }, ShouldHandle = args => new ValueTask<bool>( args.Outcome.Result?.StatusCode == HttpStatusCode.TooManyRequests ) });
Una regola pratica per i retry: usa al massimo 3-4 tentativi per operazioni sincrone utente-facing (i secondi contano), fino a 10 per job in background dove la latenza e' meno critica.
Circuit Breaker: proteggere il sistema dai fallimenti a cascata
Il circuit breaker prende il nome dall'omonimo dispositivo elettrico: quando la corrente supera una soglia di sicurezza, il circuito si apre interrompendo il flusso. In software, quando un servizio supera una soglia di fallimento, il circuit breaker "apre" le chiamate verso quel servizio, proteggendo sia il sistema chiamante che quello chiamato.
Il problema che risolve e' sottile ma critico: senza circuit breaker, un retry policy risponde ai fallimenti aumentando il traffico verso un servizio gia' in difficolta'. Questo crea un circolo vizioso dove il servizio che fatica a rispondere viene ulteriormente sommerso da richieste in retry, peggiorando la situazione anziche' migliorarla.
I tre stati del circuit breaker
Un circuit breaker ha tre stati ben definiti:
- Closed (chiuso): stato normale, le chiamate passano attraverso. Il circuit breaker monitora il tasso di fallimento.
- Open (aperto): il tasso di fallimento ha superato la soglia. Le chiamate vengono bloccate immediatamente con
BrokenCircuitException, senza nemmeno tentare la chiamata reale. Il servizio chiamato riceve zero traffico e ha tempo per riprendersi. - Half-Open (semi-aperto): dopo il periodo di breaker, viene ammesso un numero limitato di chiamate di test. Se vanno a buon fine, il circuit breaker torna Closed. Se falliscono, torna Open per un altro periodo.
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { // Apre il circuito quando il 50% delle ultime chiamate ha fallito FailureRatio = 0.5, // Minimo di chiamate prima di valutare il ratio (evita falsi positivi su traffico basso) MinimumThroughput = 10, // Finestra temporale per il calcolo del ratio SamplingDuration = TimeSpan.FromSeconds(30), // Quanto tempo il circuito resta aperto prima di passare a Half-Open BreakDuration = TimeSpan.FromSeconds(60), // Callbacks per observability OnOpened = args => { _logger.LogError( "Circuit breaker APERTO per il servizio. " + "Motivo: {Outcome}. Durata: {Duration}s", args.Outcome.Exception?.Message ?? args.Outcome.Result?.StatusCode.ToString(), args.BreakDuration.TotalSeconds); // Emetti una metrica/alert qui _metrics.CircuitBreakerOpened.Add(1, new KeyValuePair<string, object?>("service", "mio-servizio")); return ValueTask.CompletedTask; }, OnHalfOpened = args => { _logger.LogInformation("Circuit breaker in stato Half-Open. Test in corso..."); return ValueTask.CompletedTask; }, OnClosed = args => { _logger.LogInformation("Circuit breaker CHIUSO. Il servizio e' tornato operativo."); return ValueTask.CompletedTask; } });
Gestire BrokenCircuitException nel codice applicativo
Quando il circuit breaker e' aperto, le chiamate lanciano BrokenCircuitException. Il codice applicativo deve gestirla esplicitamente, tipicamente restituendo un risultato di fallback o propagando un errore significativo all'utente:
public async Task<OrdineDto?> GetOrdineAsync(int id, CancellationToken ct) { try { return await _httpClient.GetFromJsonAsync<OrdineDto>($"/ordini/{id}", ct); } catch (BrokenCircuitException ex) { _logger.LogWarning("Circuit breaker aperto. Restituisco dati dalla cache locale."); // Tenta di leggere dalla cache return await _cache.GetAsync<OrdineDto>($"ordine:{id}", ct); } catch (Exception ex) { _logger.LogError(ex, "Errore nel recupero dell'ordine {Id}", id); throw; } }
Timeout policy: definire limiti espliciti alle operazioni lente
I timeout sono il meccanismo di difesa piu' semplice e quello piu' spesso trascurato. Il default di HttpClient in .NET e' 100 secondi: in produzione, questo significa che una chiamata che non risponde tiene occupato un thread (e potenzialmente una connessione al connection pool) per quasi due minuti.
Con Polly puoi definire due livelli di timeout distinti: uno per singolo tentativo e uno per l'intera operazione (inclusi i retry). Questa distinzione e' fondamentale.
// In una pipeline con retry, definisci due timeout: // 1. Timeout per singolo tentativo (piu' breve) // 2. Timeout totale per l'intera operazione (inclusi retry) builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>() .AddResilienceHandler("order-service", pipeline => { // Timeout TOTALE per l'operazione (compresi tutti i retry) // 15s = 3 tentativi x 3s ciascuno + margine per i delay tra retry pipeline.AddTimeout(new HttpTimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(15), OnTimeout = args => { _logger.LogError( "TIMEOUT TOTALE per {OperationKey} dopo {Duration}s", args.Context.OperationKey, args.Timeout.TotalSeconds); return ValueTask.CompletedTask; } }); // Retry con massimo 3 tentativi pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 2, Delay = TimeSpan.FromSeconds(1), BackoffType = DelayBackoffType.Exponential }); // Timeout per SINGOLO tentativo pipeline.AddTimeout(new HttpTimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(4) }); });
L'ordine e' critico: il timeout totale deve essere aggiunto prima del retry nella catena, e il timeout per singolo tentativo dopo. Cosi' il timeout totale avvolge l'intera operazione retry, mentre quello per singolo tentativo si azzera ad ogni nuovo tentativo.
Calcolare i valori di timeout corretti
Come si scelgono i valori? Una formula pratica per le API di terze parti: misura il 95° percentile della latenza normale, moltiplica per 2.5 per il timeout del singolo tentativo. Per il timeout totale: (timeout singolo x numero di retry) + (delay massimo tra retry x numero di retry) + margine del 20%.
Per le API LLM la situazione e' diversa: le risposte per prompt complessi possono richiedere 30-60 secondi. Qui il timeout deve essere abbastanza generoso da coprire la latenza reale, non troppo aggressivo da tagliare risposte legittime.
Bulkhead e Rate Limiter: limitare la concorrenza per proteggere le risorse
Il bulkhead (paratia, come sulle navi) e' un pattern che isola le risorse: limita quante richieste concorrenti possono andare verso un determinato servizio, e opzionalmente quante possono accodarsi in attesa. L'obiettivo e' prevenire che un servizio lento consumi tutte le risorse disponibili (thread pool, connessioni, memoria) impattando le altre funzionalita' dell'applicazione.
// Bulkhead: max 10 richieste concorrenti al servizio di inventario, // con una coda di max 20 richieste in attesa pipeline.AddConcurrencyLimiter(new ConcurrencyLimiterStrategyOptions { MaxConcurrentExecutions = 10, QueueingStrategy = QueueingStrategy.DropOldest, QueueCapacity = 20, OnQueueFull = args => { _logger.LogWarning( "Coda del bulkhead piena per {OperationKey}. Richiesta scartata.", args.Context.OperationKey); return ValueTask.CompletedTask; } });
Con Polly v8 e' disponibile anche la strategia Rate Limiter, che si integra con il System.Threading.RateLimiting introdotto in .NET 7. Puoi usarla per applicare rate limit in uscita (quanto traffico genero io verso un servizio), diversamente dal rate limiting in entrata (quanto traffico accetto io) che si gestisce a livello di middleware ASP.NET Core.
using System.Threading.RateLimiting; // Rate limiter: massimo 100 richieste al minuto verso l'API esterna pipeline.AddRateLimiter(new RateLimiterStrategyOptions { RateLimiter = args => RateLimitLease.NoOpLease, // Usa un token bucket per 100 req/min DefaultRateLimiterOptions = new TokenBucketRateLimiterOptions { TokenLimit = 100, ReplenishmentPeriod = TimeSpan.FromMinutes(1), TokensPerPeriod = 100, AutoReplenishment = true, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 50 }, OnRejected = args => { _logger.LogWarning("Rate limit raggiunto per {OperationKey}", args.Context.OperationKey); return ValueTask.CompletedTask; } });
Fallback: cosa fare quando tutto il resto fallisce
Il fallback e' l'ultima linea di difesa nella pipeline di resilienza. Quando retry, circuit breaker e tutto il resto non riescono a ottenere una risposta positiva, il fallback definisce cosa restituire. Puo' essere un valore di default, dati letti dalla cache, una risposta parziale, o un errore strutturato piu' significativo di una generica eccezione.
Il fallback non e' una soluzione al problema, e' una gestione controllata del fallimento. La differenza tra una buona gestione e una cattiva e' la differenza tra un utente che vede "dati aggiornati a 5 minuti fa" e un utente che vede una pagina di errore 500.
// Fallback per un servizio di catalogo prodotti pipeline.AddFallback(new FallbackStrategyOptions<IEnumerable<ProdottoDto>> { // Specifica per quali esiti si attiva il fallback ShouldHandle = new PredicateBuilder<IEnumerable<ProdottoDto>>() .Handle<BrokenCircuitException>() // Circuit breaker aperto .Handle<TimeoutRejectedException>() // Timeout scattato .Handle<HttpRequestException>() // Errore di rete .HandleResult(r => r == null), // Risposta nulla FallbackAction = async args => { _logger.LogWarning( "Fallback attivato per {OperationKey}. Carico dati dalla cache.", args.Context.OperationKey); // Tenta la cache var cached = await _cache.GetAsync<List<ProdottoDto>>("catalogo:all"); if (cached?.Count > 0) return Outcome.FromResult<IEnumerable<ProdottoDto>>(cached); // Se neanche la cache ha dati, restituisci una lista vuota // piuttosto che propagare l'eccezione _logger.LogError("Cache vuota. Restituisco lista vuota come fallback estremo."); return Outcome.FromResult<IEnumerable<ProdottoDto>>( Enumerable.Empty<ProdottoDto>()); }, OnFallback = args => { _metrics.FallbackActivated.Add(1, new KeyValuePair<string, object?>("operation", args.Context.OperationKey)); return ValueTask.CompletedTask; } });
Pattern Cache-Aside con Polly
Un pattern molto comune in produzione combina Polly con una cache distribuita (Redis, Memory Cache) per implementare il pattern Cache-Aside resiliente. Quando il servizio primario e' irraggiungibile, il fallback serve i dati dalla cache. Quando la cache e' anch'essa vuota (cold start o invalidazione), il sistema e' configurato per rispondere con dati di default piuttosto che con un errore.
// Servizio con Cache-Aside pattern e Polly public class ProdottoService { private readonly ResiliencePipeline<IReadOnlyList<ProdottoDto>> _pipeline; private readonly IDistributedCache _cache; private readonly ILogger<ProdottoService> _logger; public ProdottoService( ResiliencePipelineProvider<string> pipelineProvider, IDistributedCache cache, ILogger<ProdottoService> logger) { _pipeline = pipelineProvider.GetPipeline<IReadOnlyList<ProdottoDto>>("catalogo"); _cache = cache; _logger = logger; } public async Task<IReadOnlyList<ProdottoDto>> GetProdottiAsync(CancellationToken ct) { var cacheKey = "prodotti:all"; // Prima controlla la cache (fast path) var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached != null) return JsonSerializer.Deserialize<List<ProdottoDto>>(cached)!; // Poi tenta il servizio con la pipeline di resilienza return await _pipeline.ExecuteAsync(async token => { var prodotti = await _catalogoClient.GetProdottiAsync(token); // Aggiorna la cache in background _ = _cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(prodotti), new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(10) }); return prodotti; }, ct); } }
Combinare le policy in pipeline: l'ordine che conta e le trappole da evitare
Quando combini piu' strategie in una pipeline, l'ordine con cui le aggiungi determina l'ordine con cui vengono attraversate. In Polly v8, le strategie vengono aggiunte dall'esterno verso l'interno: la prima aggiunta e' la piu' esterna (la prima a intercettare la chiamata e l'ultima a vedere il risultato).
L'ordine consigliato per una pipeline completa, dall'esterno verso l'interno:
- Fallback: il piu' esterno, intercetta qualsiasi eccezione non gestita da tutti gli altri layer
- Timeout totale: limita il tempo massimo dell'intera operazione inclusi i retry
- Retry: riprova l'operazione se fallisce
- Circuit Breaker: intercetta i retry e blocca le chiamate quando il servizio e' in fault
- Timeout per singolo tentativo: il piu' interno, si applica ad ogni singolo tentativo
// Pipeline completa nell'ordine corretto per produzione builder.Services.AddHttpClient<IInventarioClient, InventarioClient>() .AddResilienceHandler("inventario-pipeline", pipeline => { // 1. Fallback (piu' esterno) pipeline.AddFallback(new HttpFallbackStrategyOptions { FallbackAction = args => Outcome.FromResultAsValueTask( new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { fromCache = true, items = Array.Empty<object>() }) }), ShouldHandle = new PredicateBuilder<HttpResponseMessage>() .Handle<BrokenCircuitException>() .Handle<TimeoutRejectedException>() }); // 2. Timeout totale: 20 secondi per tutta l'operazione pipeline.AddTimeout(TimeSpan.FromSeconds(20)); // 3. Retry: massimo 3 tentativi con backoff esponenziale pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(2), BackoffType = DelayBackoffType.Exponential, UseJitter = true }); // 4. Circuit Breaker: apre dopo 50% di fallimenti su 10+ chiamate pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = 10, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(60) }); // 5. Timeout per singolo tentativo: 4 secondi pipeline.AddTimeout(TimeSpan.FromSeconds(4)); });
Le trappole piu' comuni nella composizione
La trappola piu' frequente e' mettere il circuit breaker prima del retry. Con questo ordine, il circuit breaker vede solo il risultato finale dell'operazione di retry (non ogni singolo tentativo), perdendo la capacita' di aprirsi rapidamente durante una serie di fallimenti. Il circuit breaker deve essere piu' interno rispetto al retry per intercettare ogni singolo tentativo.
Un'altra trappola: dimenticare il timeout totale e mettere solo quello per singolo tentativo. Con 3 retry, un timeout di 10 secondi per tentativo, e backoff esponenziale (2s, 4s, 8s di attesa), l'operazione totale puo' richiedere fino a 10 + 2 + 10 + 4 + 10 + 8 + 10 = 54 secondi. In molti scenari questo e' inaccettabile per un endpoint user-facing.
Polly con HttpClientFactory in ASP.NET Core: il pattern standard 2026
L'integrazione di Polly con HttpClientFactory in ASP.NET Core e' il modo consigliato per utilizzare la resilienza HTTP in qualsiasi applicazione moderna. HttpClientFactory gestisce il lifecycle degli HttpClient evitando i problemi di socket exhaustion tipici della creazione manuale, e l'integrazione con Polly tramite AddResilienceHandler aggiunge la resilienza in modo trasparente.
Il package Microsoft.Extensions.Http.Resilience offre due livelli di astrazione:
- AddStandardResilienceHandler(): configurazione opinionata con valori di default ragionevoli, ottima per iniziare rapidamente.
- AddResilienceHandler(): configurazione completa e personalizzabile, per scenari con requisiti specifici.
// Program.cs - Setup completo con HttpClientFactory e Polly v8 var builder = WebApplication.CreateBuilder(args); // Opzione 1: Standard Resilience Handler (defaults ragionevoli) builder.Services.AddHttpClient("api-esterna-standard") .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.servizio-esterno.it")) .AddStandardResilienceHandler(options => { // Personalizza solo cio' che differisce dai default options.Retry.MaxRetryAttempts = 4; options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30); options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20); }); // Opzione 2: Resilience Handler personalizzato (controllo completo) builder.Services.AddHttpClient<IECommerceApiClient, ECommerceApiClient>() .ConfigureHttpClient(c => { c.BaseAddress = new Uri(builder.Configuration["Services:ECommerce:BaseUrl"]!); c.DefaultRequestHeaders.Add("X-Api-Key", builder.Configuration["Services:ECommerce:ApiKey"]); }) .AddResilienceHandler("ecommerce-resilience", (pipeline, context) => { // Puoi accedere ai servizi del DI container tramite context.ServiceProvider var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>(); pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(1), BackoffType = DelayBackoffType.Exponential, UseJitter = true, OnRetry = args => { logger.LogWarning( "Retry #{Attempt} per ECommerce API. Outcome: {Outcome}", args.AttemptNumber, args.Outcome.Exception?.GetType().Name ?? "HTTP " + (int)(args.Outcome.Result?.StatusCode ?? 0)); return ValueTask.CompletedTask; } }); pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = 8, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(45) }); pipeline.AddTimeout(TimeSpan.FromSeconds(8)); }); // Registra i client tipizzati builder.Services.AddScoped<IECommerceApiClient, ECommerceApiClient>(); var app = builder.Build();
Polly su chiamate non-HTTP: database, code, file system
Polly non e' solo per HttpClient. Qualsiasi operazione che puo' fallire in modo transitorio e' un candidato per la resilienza. Per i database in particolare, le eccezioni transitorie sono comuni: deadlock, timeout di connessione, brevi indisponibilita' del server.
Per questo tipo di scenari, si usa ResiliencePipelineProvider iniettato dal DI container, o un pipeline costruito direttamente con ResiliencePipelineBuilder:
// In Program.cs: registra pipeline non-HTTP nel DI container builder.Services.AddResiliencePipeline("database-operations", pipelineBuilder => { pipelineBuilder .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(500), BackoffType = DelayBackoffType.Exponential, ShouldHandle = new PredicateBuilder() // Riprova solo per eccezioni transitorie SQL Server .Handle<SqlException>(ex => IsTransientSqlError(ex.Number)) .Handle<TimeoutException>() }) .AddTimeout(TimeSpan.FromSeconds(15)); }); // Nel repository/servizio public class OrdineRepository { private readonly ResiliencePipeline _dbPipeline; private readonly DbContext _context; public OrdineRepository( ResiliencePipelineProvider<string> pipelineProvider, AppDbContext context) { _dbPipeline = pipelineProvider.GetPipeline("database-operations"); _context = context; } public async Task<Ordine?> GetByIdAsync(int id, CancellationToken ct) { return await _dbPipeline.ExecuteAsync( async token => await _context.Ordini .Include(o => o.Righe) .FirstOrDefaultAsync(o => o.Id == id, token), ct); } } // Helper per identificare errori SQL transitorie private static bool IsTransientSqlError(int errorNumber) => errorNumber is 1205 // Deadlock or 1222 // Lock timeout or -2 // Client timeout or 40613 // Database non disponibile (Azure SQL) or 40501 // Service busy or 40197; // Service error
Observability con Polly: logging, metriche e telemetria con OpenTelemetry
Una pipeline di resilienza che non produce metriche e' come un circuit breaker senza indicatore di stato: non sai quando si apre, non sai con quale frequenza si attivano i retry, non sai se i tuoi timeout sono calibrati correttamente. In produzione, l'observability di Polly e' indispensabile.
Polly v8 ha un'integrazione nativa con System.Diagnostics.Metrics e OpenTelemetry. Basta aggiungere il telemetry listener e le metriche vengono emesse automaticamente per ogni strategia nella pipeline.
// Program.cs: aggiungi telemetria OpenTelemetry per Polly using OpenTelemetry.Metrics; using OpenTelemetry.Trace; builder.Services .AddOpenTelemetry() .WithMetrics(metrics => { metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() // Aggiunge automaticamente le metriche Polly .AddMeter("Polly") .AddPrometheusExporter(); // o Otlp, Console, etc. }) .WithTracing(tracing => { tracing .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() // Polly emette span per ogni tentativo .AddSource("Polly"); });
Le metriche che Polly emette automaticamente:
resilience.polly.strategy.events: conta gli eventi per tipo (retry, circuit-breaker-state-change, timeout, etc.) con tag per pipeline name e tipo di evento.resilience.polly.strategy.duration: istogramma della durata delle esecuzioni.resilience.polly.strategy.attempts: numero di tentativi per operazione.
// Configura il logging dettagliato di Polly (utile in development) builder.Services.AddResiliencePipeline("verbose-pipeline", pipelineBuilder => { // Configura la telemetria a livello di pipeline pipelineBuilder.ConfigureTelemetry(new TelemetryOptions { // Livello di log per gli eventi di resilienza LoggerFactory = LoggerFactory.Create(b => b.AddConsole()), // Severity dei log per tipo di evento Severity = new Dictionary<ResilienceEventSeverity, LogLevel> { { ResilienceEventSeverity.Warning, LogLevel.Warning }, { ResilienceEventSeverity.Error, LogLevel.Error }, { ResilienceEventSeverity.Information, LogLevel.Information } } }); pipelineBuilder .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) .AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.5 }) .AddTimeout(TimeSpan.FromSeconds(10)); });
Dashboard Grafana per Polly
Con Prometheus e Grafana, le metriche di Polly diventano dashboard operative. I pannelli piu' utili da configurare:
- Tasso di retry per client/pipeline: un aumento improvviso indica degradazione di un servizio esterno.
- Stato del circuit breaker per pipeline: indica i servizi attualmente in fault.
- Percentuale di timeout: indica se i valori sono calibrati troppo aggressivamente.
- Fallback rate: quanto spesso il sistema sta servendo dati degradati.
Un consiglio pratico: imposta alert su Grafana per circuit breaker opened e tasso di retry superiore al 20% su una finestra di 5 minuti. Questi sono i segnali piu' precoci di problemi nelle dipendenze esterne.
Polly negli agenti AI: resilienza per chiamate a provider LLM
Se costruisci agenti AI con Semantic Kernel o sistemi RAG che chiamano provider LLM, Polly diventa ancora piu' critico. I provider LLM hanno pattern di fallimento peculiari: rate limit aggressivi con finestre di 60 secondi, latenze altamente variabili (da 500ms a 60s a seconda della lunghezza della risposta), aggiornamenti dei modelli che causano brevi periodi di indisponibilita'.
Per approfondire la costruzione di agenti AI con .NET e Semantic Kernel, abbiamo dedicato un articolo completo al tema. Qui ci concentriamo su come Polly protegge queste chiamate.
// Configurazione Polly per chiamate a Azure OpenAI / OpenAI builder.Services.AddHttpClient("openai-client") .ConfigureHttpClient(c => { c.BaseAddress = new Uri("https://api.openai.com"); c.Timeout = Timeout.InfiniteTimeSpan; // Polly gestisce i timeout }) .AddResilienceHandler("llm-resilience", pipeline => { // Timeout totale per tutta la catena (inclusi retry) // 90s per prompt complessi pipeline.AddTimeout(TimeSpan.FromSeconds(90)); // Retry con rispetto dell'header Retry-After pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, ShouldHandle = args => new ValueTask<bool>( args.Outcome.Result?.StatusCode == HttpStatusCode.TooManyRequests || args.Outcome.Result?.StatusCode == HttpStatusCode.ServiceUnavailable || args.Outcome.Exception is HttpRequestException ), DelayGenerator = args => { if (args.Outcome.Result?.Headers.RetryAfter?.Delta is TimeSpan retryAfter) return new ValueTask<TimeSpan?>(retryAfter + TimeSpan.FromSeconds(1)); return new ValueTask<TimeSpan?>( TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber + 1))); } }); // Circuit breaker: apri dopo 60% di fallimenti su 5 chiamate in 2 minuti pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.6, MinimumThroughput = 5, SamplingDuration = TimeSpan.FromMinutes(2), BreakDuration = TimeSpan.FromMinutes(3) }); // Timeout per singolo tentativo LLM pipeline.AddTimeout(TimeSpan.FromSeconds(60)); });
Le applicazioni distribuite con microservizi hanno in genere piu' layer di chiamate API esterne, ognuno dei quali va protetto con una pipeline di resilienza dedicata. Non condividere la stessa pipeline tra servizi con requisiti diversi: un servizio di pagamento deve avere timeout piu' generosi e circuit breaker piu' conservativi di un servizio di notifiche non critico.
Polly in produzione: checklist e configurazioni per ambiente
La configurazione di Polly dovrebbe variare tra ambiente di sviluppo e produzione. In sviluppo vuoi vedere cosa succede: log verbosi, timeout corti, retry visibili. In produzione vuoi performance: log essenziali, configurazioni ottimizzate, metriche raccolte.
// Configurazione condizionale per ambiente builder.Services.AddHttpClient<IServizioEsterno, ServizioEsterno>() .AddResilienceHandler("servizio-esterno", (pipeline, context) => { var env = context.ServiceProvider.GetRequiredService<IHostEnvironment>(); var config = context.ServiceProvider.GetRequiredService<IConfiguration>(); // Leggi la configurazione da appsettings var retryCount = config.GetValue<int>("Resilience:ServizioEsterno:RetryCount", 3); var timeoutSeconds = config.GetValue<int>("Resilience:ServizioEsterno:TimeoutSeconds", 10); pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = env.IsDevelopment() ? 1 : retryCount, Delay = TimeSpan.FromSeconds(env.IsDevelopment() ? 0.1 : 2), BackoffType = DelayBackoffType.Exponential, UseJitter = true }); pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = env.IsDevelopment() ? 3 : 10, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(env.IsDevelopment() ? 5 : 60) }); pipeline.AddTimeout(TimeSpan.FromSeconds( env.IsDevelopment() ? timeoutSeconds / 2 : timeoutSeconds)); });
Rendi i valori di configurazione esterni (appsettings.json, Azure App Configuration, Kubernetes ConfigMaps). Quando si verifica un problema in produzione, poter aggiustare il BreakDuration del circuit breaker senza un rideploy puo' fare la differenza tra 5 minuti e 30 minuti di downtime.
Testing della configurazione di resilienza
Come si testano le policy di resilienza? Per i test unitari, usa ResiliencePipelineBuilder direttamente con un mock del client HTTP. Per i test di integrazione, simula i fallimenti con librerie come WireMock.NET che permettono di configurare risposte lente, errori e circuit breaker artificiali.
// Test unitario per verificare il comportamento del retry [Fact] public async Task GetOrdini_RiprovaTreeVolte_QuandoServizioNonDisponibile() { // Arrange var tentativiEffettuati = 0; var pipeline = new ResiliencePipelineBuilder<IReadOnlyList<OrdineDto>>() .AddRetry(new RetryStrategyOptions<IReadOnlyList<OrdineDto>> { MaxRetryAttempts = 3, Delay = TimeSpan.Zero, // Nessun delay nei test ShouldHandle = new PredicateBuilder<IReadOnlyList<OrdineDto>>() .Handle<HttpRequestException>() }) .Build(); // Act & Assert var ex = await Assert.ThrowsAsync<HttpRequestException>(async () => { await pipeline.ExecuteAsync(async ct => { tentativiEffettuati++; throw new HttpRequestException("Servizio non disponibile"); }, CancellationToken.None); }); // 1 tentativo originale + 3 retry = 4 tentativi totali Assert.Equal(4, tentativiEffettuati); }
Per approfondire la gestione delle eccezioni in C# nel contesto piu' ampio della robustezza del codice, ti consiglio di leggere il nostro articolo dedicato.
Polly non e' un'opzione per le applicazioni .NET in produzione: e' un requisito. Ogni chiamata a un sistema esterno senza una policy di resilienza e' un punto di fallimento non gestito che prima o poi colpira' i tuoi utenti.Testing delle pipeline Polly con xUnit: come verificare la resilienza senza aspettare
Testare la resilienza e' una delle aree dove piu' spesso si tagliano i tempi, e dove il costo si paga in produzione. "Ho configurato il retry, funzionera'" e' una frase che si sente spesso. In realta', senza test automatizzati, non sai se il circuit breaker si apre davvero, se i retry rispettano il backoff configurato, o se il fallback restituisce i dati corretti quando tutto il resto fallisce.
Il problema principale nel testare Polly e' il tempo: una pipeline con 3 retry, backoff esponenziale e timeout da 10 secondi puo' richiedere diversi minuti per completare i test sui casi di errore. La soluzione e' usare il FakeTimeProvider introdotto in .NET 8, che permette di controllare il tempo virtuale nei test senza aspettare i delay reali.
FakeTimeProvider per testare i delay senza attendere
Con Microsoft.Extensions.TimeProvider.Testing (disponibile come package NuGet separato), puoi avanzare il tempo virtuale durante i test, eliminando l'attesa reale dei delay tra retry. Polly v8 e' costruito attorno a TimeProvider come abstraction, quindi e' nativamente compatibile.
// NuGet: Microsoft.Extensions.TimeProvider.Testing // NuGet: Polly (v8+) using Microsoft.Extensions.Time.Testing; using Polly; using Polly.Retry; public class RetryPipelineTests { [Fact] public async Task Pipeline_RetryConBackoffEsponenziale_NonAspettaTempoDiTest() { // Arrange var fakeTime = new FakeTimeProvider(); var tentativiEffettuati = 0; var delays = new List<TimeSpan>(); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(2), BackoffType = DelayBackoffType.Exponential, UseJitter = false, // Disabilita jitter nei test per risultati deterministici ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>(), OnRetry = args => { delays.Add(args.RetryDelay); return ValueTask.CompletedTask; } }) // Inietta il FakeTimeProvider per controllare il tempo .ConfigureDefaults(o => o.TimeProvider = fakeTime) .Build(); // Act var task = pipeline.ExecuteAsync(async ct => { tentativiEffettuati++; throw new HttpRequestException("Servizio non disponibile"); }, CancellationToken.None); // Avanza il tempo virtuale per ogni retry // senza aspettare i secondi reali for (int i = 0; i < 3; i++) { await Task.Yield(); // Lascia avanzare il loop asincrono fakeTime.Advance(TimeSpan.FromSeconds(10)); // Avanza oltre il delay massimo } // Assert await Assert.ThrowsAsync<HttpRequestException>(() => task.AsTask()); Assert.Equal(4, tentativiEffettuati); // 1 originale + 3 retry Assert.Equal(3, delays.Count); // Verifica che il backoff esponenziale funzioni correttamente // Con delay base 2s: 2s, 4s, 8s Assert.Equal(TimeSpan.FromSeconds(2), delays[0]); Assert.Equal(TimeSpan.FromSeconds(4), delays[1]); Assert.Equal(TimeSpan.FromSeconds(8), delays[2]); } }
Simulare errori con mock HTTP handlers
Per testare le pipeline su HttpClient, l'approccio consigliato e' usare un DelegatingHandler personalizzato che simula le risposte del server. Questo permette di testare scenari come: i primi N tentativi falliscono con 503, poi il servizio risponde con 200.
// Handler che simula fallimenti controllati public class FaultInjectingHandler : DelegatingHandler { private int _tentativoCorrente = 0; private readonly int _fallimentiDaSimulare; private readonly HttpStatusCode _codiceErrore; public FaultInjectingHandler( int fallimentiDaSimulare, HttpStatusCode codiceErrore = HttpStatusCode.ServiceUnavailable) { _fallimentiDaSimulare = fallimentiDaSimulare; _codiceErrore = codiceErrore; } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref _tentativoCorrente); if (_tentativoCorrente <= _fallimentiDaSimulare) return new HttpResponseMessage(_codiceErrore); // Dal tentativo (_fallimentiDaSimulare + 1) in poi: risposta positiva return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { success = true }) }; } } // Test: verifica che dopo 2 fallimenti, il terzo tentativo riesca public class HttpClientResilienceTests { [Fact] public async Task GetAsync_RiesceAlTerzoTentativo_DopodueFallimenti() { // Arrange var faultHandler = new FaultInjectingHandler(fallimentiDaSimulare: 2); var fakeTime = new FakeTimeProvider(); var httpClient = new HttpClient(faultHandler) { BaseAddress = new Uri("https://api.test.local") }; var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>() .AddRetry(new RetryStrategyOptions<HttpResponseMessage> { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(1), ShouldHandle = new PredicateBuilder<HttpResponseMessage>() .HandleResult(r => !r.IsSuccessStatusCode) }) .ConfigureDefaults(o => o.TimeProvider = fakeTime) .Build(); // Act var cts = new CancellationTokenSource(); var task = pipeline.ExecuteAsync(async ct => await httpClient.GetAsync("/api/ordini", ct), cts.Token); // Avanza il tempo per ogni retry fakeTime.Advance(TimeSpan.FromSeconds(5)); fakeTime.Advance(TimeSpan.FromSeconds(5)); var response = await task; // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(3, faultHandler.TentativiTotali); // Proprieta' esposta per i test } }
Verificare che il circuit breaker si apra e si chiuda
Testare il circuit breaker richiede un approccio leggermente diverso: devi simulare abbastanza fallimenti da raggiungere la soglia di apertura, verificare che le chiamate successive vengano bloccate immediatamente, e infine verificare che dopo il BreakDuration il circuito passi a Half-Open.
[Fact] public async Task CircuitBreaker_SiApre_DopoFallimentiSufficienti() { // Arrange var fakeTime = new FakeTimeProvider(); var circuitAperto = false; var circuitChiuso = false; var pipeline = new ResiliencePipelineBuilder() .AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = 4, // Apre dopo 4 chiamate con 50% fallimenti SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(10), OnOpened = args => { circuitAperto = true; return ValueTask.CompletedTask; }, OnClosed = args => { circuitChiuso = true; return ValueTask.CompletedTask; }, ShouldHandle = new PredicateBuilder().Handle<InvalidOperationException>() }) .ConfigureDefaults(o => o.TimeProvider = fakeTime) .Build(); // Simula 4 fallimenti consecutivi per aprire il circuito for (int i = 0; i < 4; i++) { try { await pipeline.ExecuteAsync(ct => { throw new InvalidOperationException("Servizio in errore"); #pragma warning disable CS0162 return ValueTask.CompletedTask; #pragma warning restore CS0162 }); } catch (InvalidOperationException) { /* Atteso */ } } // Verifica che il circuito sia aperto Assert.True(circuitAperto); // Verifica che ulteriori chiamate vengano bloccate con BrokenCircuitException await Assert.ThrowsAsync<BrokenCircuitException>(async () => await pipeline.ExecuteAsync(ct => ValueTask.CompletedTask)); // Avanza il tempo oltre il BreakDuration fakeTime.Advance(TimeSpan.FromSeconds(15)); // Ora il circuito dovrebbe essere in Half-Open: // la prossima chiamata viene ammessa come test // Se ha successo, il circuito si richiude await pipeline.ExecuteAsync(ct => ValueTask.CompletedTask); Assert.True(circuitChiuso); }
Questi test vanno eseguiti in CI/CD come parte della suite di test di integrazione. Non sono test veloci, ma con FakeTimeProvider sono abbastanza rapidi da non rallentare la pipeline in modo significativo. Il valore che portano e' molto superiore al costo: sapere che il tuo circuit breaker si apre e si chiude come configurato e' una delle garanzie piu' importanti per un sistema distribuito.
Polly con OpenAI e Azure OpenAI: gestire rate limit, Retry-After e fallback su provider alternativo
Le API dei provider LLM hanno un profilo di fallimento diverso da qualsiasi altro servizio HTTP che userai. OpenAI e Azure OpenAI hanno rate limit per tier di abbonamento che si esprimono in RPM (Requests Per Minute) e TPM (Tokens Per Minute). Quando si superano questi limiti, il server risponde con 429 Too Many Requests e include un header Retry-After che indica esattamente quanti secondi aspettare.
Ignorare l'header Retry-After e usare un backoff esponenziale generico e' un errore comune: se OpenAI dice di aspettare 20 secondi e tu riprovi dopo 4, sprechi il tentativo (ottieni un altro 429), bruci il rate limit e allunghi inutilmente il tempo di recupero. La strategia corretta e' leggere il valore dall'header e rispettarlo, con un piccolo margine di sicurezza.
Il DelayGenerator che legge l'header Retry-After
// Servizio helper per gestire la resilienza verso provider LLM public static class LlmResilienceExtensions { public static IHttpClientBuilder AddLlmResilienceHandler( this IHttpClientBuilder builder, string pipelineName = "llm-pipeline") { return builder.AddResilienceHandler(pipelineName, (pipeline, context) => { var logger = context.ServiceProvider .GetRequiredService<ILogger<Program>>(); // Timeout totale: include il tempo di generazione del testo // 120s per prompt complessi con risposte lunghe pipeline.AddTimeout(new HttpTimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(120), OnTimeout = args => { logger.LogError( "Timeout totale scattato per chiamata LLM dopo {Seconds}s", args.Timeout.TotalSeconds); return ValueTask.CompletedTask; } }); // Retry con rispetto dell'header Retry-After pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 4, ShouldHandle = new PredicateBuilder<HttpResponseMessage>() .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests || r.StatusCode == HttpStatusCode.ServiceUnavailable || r.StatusCode == HttpStatusCode.GatewayTimeout || (int)r.StatusCode == 529) // Overloaded (Anthropic) .Handle<HttpRequestException>(), // Legge Retry-After e usa il valore esatto del provider DelayGenerator = static args => { var response = args.Outcome.Result; if (response?.Headers.RetryAfter is { } retryAfter) { // Retry-After puo' essere un delta (secondi) o una data assoluta var delay = retryAfter.Delta ?? (retryAfter.Date.HasValue ? retryAfter.Date.Value - DateTimeOffset.UtcNow : null); if (delay.HasValue && delay.Value > TimeSpan.Zero) { // Aggiungi 2 secondi di margine di sicurezza return new ValueTask<TimeSpan?>(delay.Value + TimeSpan.FromSeconds(2)); } } // Fallback: backoff esponenziale se non c'e' Retry-After var exponentialDelay = TimeSpan.FromSeconds( Math.Pow(2, args.AttemptNumber + 2)); // 4s, 8s, 16s, 32s return new ValueTask<TimeSpan?>(exponentialDelay); }, OnRetry = args => { var statusCode = args.Outcome.Result?.StatusCode; var retryAfterHeader = args.Outcome.Result?.Headers.RetryAfter?.Delta; logger.LogWarning( "Retry #{Attempt} per chiamata LLM. Status: {Status}. " + "Retry-After: {RetryAfter}s. Prossimo tentativo tra: {Delay}s", args.AttemptNumber + 1, statusCode, retryAfterHeader?.TotalSeconds, args.RetryDelay.TotalSeconds); return ValueTask.CompletedTask; } }); // Circuit breaker: conservativo per LLM // Non vogliamo sprecare token su un modello che sta avendo problemi pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.6, // 60% di fallimenti su minimo 5 chiamate MinimumThroughput = 5, SamplingDuration = TimeSpan.FromMinutes(3), BreakDuration = TimeSpan.FromMinutes(2), OnOpened = args => { logger.LogError( "Circuit breaker LLM APERTO. " + "Il provider non sta rispondendo correttamente. " + "Durata pausa: {Duration}min", args.BreakDuration.TotalMinutes); return ValueTask.CompletedTask; } }); // Timeout per singolo tentativo LLM pipeline.AddTimeout(TimeSpan.FromSeconds(90)); }); } }
Fallback su provider alternativo quando il primario e' sovraccarico
Per le applicazioni business-critical che dipendono da LLM, un pattern avanzato e' il fallback su un provider alternativo. Se Azure OpenAI e' in rate limit o non disponibile, si passa temporaneamente a OpenAI diretto, o viceversa. Questo richiede un livello di astrazione sopra i singoli client HTTP.
// Servizio LLM con fallback automatico su provider alternativo public class LlmServiceWithFallback { private readonly ResiliencePipeline<string> _pipeline; private readonly ILogger<LlmServiceWithFallback> _logger; public LlmServiceWithFallback( IHttpClientFactory httpClientFactory, ILogger<LlmServiceWithFallback> logger) { _logger = logger; // Costruisci la pipeline con fallback su provider alternativo _pipeline = new ResiliencePipelineBuilder<string>() // Fallback: se il provider primario fallisce, usa quello secondario .AddFallback(new FallbackStrategyOptions<string> { ShouldHandle = new PredicateBuilder<string>() .Handle<BrokenCircuitException>() .Handle<TimeoutRejectedException>() .Handle<HttpRequestException>(), FallbackAction = async args => { logger.LogWarning( "Provider LLM primario non disponibile. " + "Attivazione fallback su provider secondario."); // Chiama il provider secondario direttamente, senza pipeline // (la pipeline principale gestisce gia' i retry per il primario) var secondaryClient = httpClientFactory.CreateClient("openai-secondary"); var result = await CallSecondaryProvider(secondaryClient, args.Context); return Outcome.FromResult(result); } }) // Timeout totale per il primario .AddTimeout(TimeSpan.FromSeconds(60)) // Retry per errori transitori sul primario .AddRetry(new RetryStrategyOptions<string> { MaxRetryAttempts = 2, ShouldHandle = new PredicateBuilder<string>() .Handle<HttpRequestException>(), Delay = TimeSpan.FromSeconds(3) }) // Circuit breaker per il primario .AddCircuitBreaker(new CircuitBreakerStrategyOptions<string> { FailureRatio = 0.7, MinimumThroughput = 3, SamplingDuration = TimeSpan.FromMinutes(2), BreakDuration = TimeSpan.FromMinutes(1) }) .Build(); } public async Task<string> GenerateAsync(string prompt, CancellationToken ct) { return await _pipeline.ExecuteAsync(async token => { // Questa lambda chiama il provider primario return await CallPrimaryProvider(prompt, token); }, ct); } private async Task<string> CallPrimaryProvider( string prompt, CancellationToken ct) { // Implementazione chiamata Azure OpenAI throw new NotImplementedException("Implementazione specifica per il progetto"); } private async Task<string> CallSecondaryProvider( HttpClient client, ResilienceContext context) { // Implementazione chiamata OpenAI diretto come fallback throw new NotImplementedException("Implementazione specifica per il progetto"); } }
La gestione della resilienza per i provider LLM e' diversa da qualsiasi altro servizio: i delay possono essere lunghi (anche 60+ secondi per risposte del Retry-After), i timeout devono essere generosi ma non infiniti, e il costo di un retry fallito non e' solo latenza ma token consumati. Configurare Polly correttamente per questi scenari fa la differenza tra un agente AI che degrada elegantemente e uno che bombarda il provider generando costi inaspettati.
Per la costruzione di agenti AI completi con .NET, Semantic Kernel e le strategie di prompt engineering piu' efficaci, consulta il nostro articolo su agenti AI con .NET e Semantic Kernel. La resilienza e' il layer che tiene tutto insieme quando l'infrastruttura LLM non coopera.
Domande frequenti
Polly e' una libreria open source per .NET che implementa pattern di resilienza come retry, circuit breaker, timeout, bulkhead e fallback. Serve a rendere le applicazioni piu' robuste di fronte a errori transitori, servizi esterni non disponibili e condizioni di sovraccarico, evitando che un singolo fallimento si propaghi a cascata.
Polly v8 introduce la Resilience Pipeline come costrutto centrale al posto delle singole Policy. La nuova API e' fortemente integrata con Microsoft.Extensions.Http.Resilience e con l'ecosistema .NET moderno. Il vecchio approccio basato su Policy.Handle e WrapAsync e' ancora supportato ma deprecato. Con v8 si definiscono pipeline usando ResiliencePipelineBuilder e si configura tramite AddResilienceHandler in ASP.NET Core.
Con Polly v8 si usa AddRetry() sulla ResiliencePipelineBuilder. Puoi configurare MaxRetryAttempts, Delay, BackoffType (costante, lineare o esponenziale) e ShouldHandle per specificare quali eccezioni o risultati causano un retry. Il jitter automatico previene il problema del thundering herd quando molti client riprovano contemporaneamente.
Il circuit breaker e' un pattern che interrompe automaticamente le chiamate verso un servizio che sta fallendo, evitando di sovraccaricare un sistema gia' in difficolta'. Ha tre stati: Closed (tutto normale), Open (le chiamate vengono bloccate senza nemmeno tentare) e Half-Open (si ammette un numero limitato di tentativi per verificare il ripristino). Si usa quando si chiama un servizio esterno o un database che puo' andare in sovraccarico.
Con ASP.NET Core si registra la pipeline di resilienza usando AddResilienceHandler() nell'estensione di IHttpClientBuilder. Cosi' ogni richiesta HTTP effettuata tramite quel client beneficia automaticamente di retry, circuit breaker e timeout, senza dover gestire manualmente la pipeline nel codice applicativo.
In condizioni normali l'overhead di Polly e' trascurabile: pochissimi microsecondi per richiesta. Il costo vero si paga solo quando si attivano i meccanismi di resilienza (retry aggiunge latenza, circuit breaker aperto accelera il fallimento). Configurato correttamente, Polly migliora le prestazioni percepite riducendo i timeout globali e le degradazioni a cascata.
