Polly .NET: guida pratica alla resilienza nel 2026
Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 25 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Ha guidato progetti enterprise, formato centinaia di sviluppatori e aiutato aziende di ogni dimensione a semplificare la complessità trasformando il software in guadagni per il business.

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:

  1. Fallback: il piu' esterno, intercetta qualsiasi eccezione non gestita da tutti gli altri layer
  2. Timeout totale: limita il tempo massimo dell'intera operazione inclusi i retry
  3. Retry: riprova l'operazione se fallisce
  4. Circuit Breaker: intercetta i retry e blocca le chiamate quando il servizio e' in fault
  5. 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.

Lascia i tuoi dati nel form qui sotto

Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 25 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Nel corso della sua carriera ha collaborato con realtà come Cotonella, Il Sole 24 Ore, FIAT e NATO, guidando team nello sviluppo di piattaforme scalabili e modernizzando ecosistemi legacy complessi.

Ha formato centinaia di sviluppatori e affiancato aziende di ogni dimensione nel trasformare il software in un vantaggio competitivo, riducendo il debito tecnico e portando risultati concreti in tempi misurabili.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.