Polly no .NET: como usar retry, circuit breaker e timeout

O Polly no .NET é a forma mais direta de proteger uma aplicação contra falhas de serviços que ela não controla. Bancos de dados, gateways de pagamento, provedores de identidade e APIs de outros times fazem parte do caminho crítico de quase toda requisição.

O problema é que esses serviços falham. Além disso, eles raramente falham de forma definitiva.

Uma falha transitória se corrige sozinha em pouco tempo. Um pico de latência, uma instância reciclada ou um deploy em andamento produzem erros que somem segundos depois.

Tratar esse tipo de falha como permanente é um erro comum em aplicações .NET. Por isso, este guia mostra como aplicar retentativas, disjuntores e tempos limite em ASP.NET Core com o .NET 10, a versão LTS de referência.

O problema de uma única tentativa

Por exemplo, imagine um endpoint que chama uma API externa e recebe um HTTP 503. A reação natural do desenvolvedor é propagar o erro para quem chamou.

Essa reação é defensável, mas frequentemente incorreta. Afinal, o 503 pode vir de uma instância que estava sendo substituída naquele exato momento.

Uma segunda tentativa dois segundos depois teria sucesso. O usuário final, no entanto, já recebeu uma mensagem de erro.

O custo dessa decisão se multiplica em microsserviços. Se uma requisição atravessa cinco serviços com 99% de disponibilidade cada, a disponibilidade composta cai para cerca de 95%.

Nesse cenário, resiliência não é opcional, e é aí que o Polly no .NET entra. Ela separa a aplicação que degrada com elegância daquela que transforma falhas pequenas em incidentes.

Se você quer revisar o conceito antes da prática, o artigo sobre o Retry Pattern explica a ideia de forma independente da biblioteca.

O pacote certo para usar Polly no .NET

Polly é a biblioteca de referência para falhas transitórias no ecossistema .NET. Ela expressa retry, circuit breaker, timeout, rate limiting, fallback e hedging de forma fluente e segura para uso concorrente.

A Microsoft mantém dois pacotes construídos sobre ela. O Microsoft.Extensions.Resilience traz mecanismos gerais, enquanto o Microsoft.Extensions.Http.Resilience foca na classe HttpClient.

Para cenários HTTP, portanto, o pacote correto é o segundo. Ele já traz o Polly no .NET como dependência.

dotnet add package Microsoft.Extensions.Http.Resilience

Contudo, um ponto merece destaque. O pacote Microsoft.Extensions.Http.Polly, comum em versões antigas e em muita documentação, está descontinuado.

Ou seja, código baseado em AddPolicyHandler e AddTransientHttpErrorPolicy pertence à geração anterior. Projetos novos devem usar AddStandardResilienceHandler ou AddResilienceHandler.

O handler padrão como ponto de partida

Antes de montar um pipeline à mão, vale conhecer o que a plataforma já entrega. A chamada AddStandardResilienceHandler aplica uma pilha de estratégias com valores alinhados às boas práticas.

builder.Services
    .AddHttpClient<CatalogClient>(client =>
    {
        client.BaseAddress = new Uri("https://api.example.com");
    })
    .AddStandardResilienceHandler();

A configuração padrão encadeia cinco estratégias, da mais externa para a mais interna. Primeiro vem o rate limiter, que permite mil execuções simultâneas com fila zerada.

Em seguida aparece o tempo limite total de trinta segundos, que cobre a execução inteira com as retentativas. Depois vem o retry, com três tentativas, backoff exponencial, jitter e atraso base de dois segundos.

O circuit breaker ocupa a quarta posição. Ele usa razão de falha de 10%, throughput mínimo de cem requisições, janela de trinta segundos e abertura de cinco segundos.

Por último, o tempo limite por tentativa encerra cada chamada que passe de dez segundos.

Retry e circuit breaker tratam códigos HTTP 500 ou superiores, além de 408 e 429. Também tratam as exceções HttpRequestException e TimeoutRejectedException.

Para a maioria das aplicações, esse conjunto basta. Assim, a configuração manual só se justifica quando a dependência exige ajustes que o padrão não cobre.

Pipeline customizado com Polly no .NET

O método AddResilienceHandler recebe um nome e um delegate que configura o ResiliencePipelineBuilder<HttpResponseMessage>. É nesse delegate que você declara as estratégias.

O exemplo abaixo registra um cliente nomeado com retentativa e backoff constante.

// Program.cs
builder.Services.AddHttpClient("DownstreamConstant",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("retry", (builder, context) =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromSeconds(2),
            BackoffType = DelayBackoffType.Constant,
            UseJitter = false,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode),
        });
    });

Quatro propriedades definem o comportamento. MaxRetryAttempts fixa o teto de repetições, e Delay define o intervalo base entre elas.

Já BackoffType determina como esse intervalo evolui. Por fim, ShouldHandle é o predicado que decide quais respostas disparam nova tentativa.

Com essa configuração, o cliente faz a chamada original e até três repetições, com dois segundos entre cada uma.

Escolhendo o tipo de backoff

O tipo de backoff é a decisão que mais afeta o sistema sob carga. No Polly no .NET, a enumeração DelayBackoffType oferece três modos.

Backoff constante

No modo constante, todas as tentativas aguardam o mesmo intervalo. Com base de dois segundos, as esperas são de dois, dois e dois segundos.

Esse modo é previsível e serve para falhas muito curtas. No entanto, ele mantém pressão constante sobre um serviço que precisa de tempo para se recuperar.

Backoff exponencial

No modo exponencial, o intervalo dobra a cada tentativa. Com base de dois segundos, as esperas passam a ser de dois, quatro e oito segundos.

// Program.cs
builder.Services.AddHttpClient("DownstreamExponential",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("retry", (builder, context) =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 4,
            Delay = TimeSpan.FromSeconds(2),
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = false,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode)
        });
    });

Por isso, esse é o comportamento recomendado no Polly no .NET. Cada tentativa concede mais espaço para o serviço se restabelecer, em vez de inundá-lo.

O contraponto é o tempo acumulado. Com quatro tentativas e base de dois segundos, a espera somada pode passar de trinta segundos, então observe o tempo limite total.

Backoff linear

No modo linear, um incremento fixo entra a cada tentativa. Com base de dois segundos, as esperas são de dois, quatro e seis segundos.

// Program.cs
builder.Services.AddHttpClient("DownstreamLinear",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("retry", (builder, context) =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 4,
            Delay = TimeSpan.FromSeconds(2),
            BackoffType = DelayBackoffType.Linear,
            UseJitter = false,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode)
        });
    });

O modo linear ocupa o meio termo entre os anteriores. Ele cresce de forma previsível, sem as esperas longas que o exponencial gera com muitas tentativas.

Jitter e o rebanho sincronizado

Intervalos fixos criam um efeito colateral pouco intuitivo. Se cem instâncias sofrem a mesma falha ao mesmo tempo, todas repetem a chamada no mesmo instante.

O resultado é um pico sincronizado sobre um serviço que já estava com dificuldade. Nesse caso, as retentativas agravam o problema que deveriam contornar.

O jitter resolve isso com uma variação aleatória em cada intervalo. Dessa forma, as tentativas se espalham no tempo.

// Program.cs
builder.Services.AddHttpClient("DownstreamJitter",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("retry", (builder, context) =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 4,
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode)
        });
    });

Com UseJitter habilitado, você não precisa informar Delay. O Polly no .NET calcula os intervalos aleatórios sobre a curva de backoff escolhida e usa dois segundos como base padrão.

Vale notar que o handler padrão já liga o jitter. Isso reforça que essa é a configuração recomendada, e não uma otimização exótica.

Circuit breaker com Polly no .NET

Retentativas resolvem falhas transitórias, mas têm um limite claro. Se a dependência está fora do ar, repetir a chamada só consome recursos e atrasa a resposta.

Por isso, o circuit breaker do Polly no .NET existe para esse cenário. Quando a taxa de falha passa de um limiar dentro de uma janela, o disjuntor abre e rejeita as chamadas na hora.

// Program.cs
builder.Services.AddHttpClient("DownstreamCircuitBreaker",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("circuit-breaker", (builder, context) =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 5,
            Delay = TimeSpan.FromSeconds(1),
            BackoffType = DelayBackoffType.Constant,
            UseJitter = false,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode)
        });

        builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(20),
            MinimumThroughput = 5,
            BreakDuration = TimeSpan.FromSeconds(20),
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .HandleResult(r => !r.IsSuccessStatusCode)
        });
    });

A leitura é direta. Se metade das requisições falhar em vinte segundos, com pelo menos cinco requisições na janela, o circuito abre.

Enquanto aberto, o pipeline falha rápido com BrokenCircuitException, sem tentar a chamada. Depois de vinte segundos, o Polly libera uma requisição de teste, e o sucesso dela fecha o circuito.

O MinimumThroughput é a propriedade mais negligenciada. Sem ele, duas falhas isoladas em horário de pouco tráfego abririam o circuito sem necessidade.

Timeout com Polly no .NET

Existe uma falha que nem retry nem circuit breaker cobrem. É o serviço que aceita a conexão e simplesmente não responde.

Nesse caso, não há erro para o Polly no .NET tratar. A requisição fica pendente e ocupa thread, socket e memória por tempo indeterminado.

// Program.cs
builder.Services.AddHttpClient("DownstreamTimeout",
    configureClient: static client =>
    {
        client.BaseAddress = new("https://localhost:7276");
    })
    .AddResilienceHandler("timeout", (builder, context) =>
    {
        builder.AddTimeout(new HttpTimeoutStrategyOptions
        {
            Timeout = TimeSpan.FromSeconds(5),
        });
    });

Passados cinco segundos sem resposta, o Polly lança uma exceção de tempo limite. Com isso, a execução termina e os recursos voltam ao pool.

Há uma consequência importante antes de definir esse valor. O tempo limite acontece no lado do chamador e não cancela o processamento na dependência.

Uma chamada que cria um pedido pode ter sucesso no serviço remoto, mesmo que sua aplicação receba timeout. Por isso, valores agressivos demais transformam sucessos em erros aparentes.

Repetindo apenas o que faz sentido

Até aqui, os exemplos de Polly no .NET usaram um predicado permissivo, que repete qualquer status diferente de sucesso. Isso ajuda na demonstração, mas não serve para produção.

Um HTTP 400 indica requisição malformada, e um 404 indica recurso inexistente. Repetir essas chamadas não muda o resultado e só desperdiça capacidade.

ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
    .HandleResult(r => r.StatusCode is
        HttpStatusCode.InternalServerError or
        HttpStatusCode.GatewayTimeout)

Esse predicado restringe as retentativas a 500 e 504, dois códigos tipicamente transitórios. Na prática, a lista costuma incluir também 408, 429, 502 e 503.

O 403 merece atenção porque gera confusão. Ele mostra que o sistema funciona e informa que a operação não é permitida, então não deve ser repetido.

Idempotência e o risco de repetir escritas

Além disso, há uma armadilha que o predicado de status não resolve. Repetir um GET é seguro, porque a operação não altera estado.

Já repetir um POST pode gerar duplicidade. Se o serviço processou a requisição e a resposta se perdeu, a retentativa cria um segundo registro.

Felizmente, o pacote de resiliência do Polly no .NET oferece extensões para esse controle.

httpClientBuilder.AddStandardResilienceHandler(options =>
{
    options.Retry.DisableForUnsafeHttpMethods();
});

O método DisableForUnsafeHttpMethods desliga retentativas para POST, PATCH, PUT, DELETE e CONNECT. Também existe a variante DisableFor, que recebe a lista explícita de métodos.

A alternativa mais robusta é tornar as escritas idempotentes por design, com chaves de idempotência. Essa abordagem exige colaboração da API de destino, e o artigo quando o código idempotente mente para você mostra suas armadilhas.

Ordem das estratégias no pipeline

A ordem em que você adiciona as estratégias define o comportamento. O builder encadeia tudo de fora para dentro, na sequência das chamadas.

Assim, a convenção do handler padrão do Polly no .NET é uma boa referência. Primeiro o rate limiter, depois o tempo limite total, o retry, o circuit breaker e, por fim, o tempo limite por tentativa.

Essa ordem garante que o tempo limite total cubra toda a sequência de retentativas. Enquanto isso, o tempo limite por tentativa atua sobre cada chamada individual.

A documentação oficial de resiliência HTTP faz uma recomendação explícita. Adicione apenas um handler por cliente e use AddResilienceHandler quando precisar combinar estratégias.

Observabilidade do Polly no .NET

Resiliência silenciosa é um risco operacional. Se as retentativas funcionam e ninguém as observa, a degradação fica invisível até virar indisponibilidade.

Por isso, cada estratégia expõe callbacks para instrumentação. Na retentativa, o OnRetry roda antes de cada nova tentativa.

OnRetry = args =>
{
    var loggerFactory = context.ServiceProvider
        .GetRequiredService<ILoggerFactory>();
    var logger = loggerFactory.CreateLogger(typeof(ResilienceEndpoints).FullName!);
    logger.LogWarning("Retry {Attempt} after {DelayMs}ms",
        args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds);
    return ValueTask.CompletedTask;
}

O circuit breaker oferece callbacks para cada transição de estado. O OnClosed, por exemplo, registra o momento em que o tráfego volta ao normal.

OnClosed = args =>
{
    var loggerFactory = context.ServiceProvider
        .GetRequiredService<ILoggerFactory>();
    var logger = loggerFactory.CreateLogger(typeof(ResilienceEndpoints).FullName!);
    logger.LogWarning("Circuit breaker closed");
    return ValueTask.CompletedTask;
}

Tempos limite seguem o mesmo padrão com o OnTimeout.

OnTimeout = args =>
{
    var loggerFactory = context.ServiceProvider
        .GetRequiredService<ILoggerFactory>();
    var logger = loggerFactory.CreateLogger(typeof(ResilienceEndpoints).FullName!);
    logger.LogWarning("Timeout after {TimeoutMs}ms", args.Timeout.TotalMilliseconds);
    return ValueTask.CompletedTask;
}

Além dos logs, existe uma camada de telemetria estruturada. A chamada AddResilienceEnricher adiciona dimensões às métricas do pipeline.

As dimensões são error.type, request.name e request.dependency.name. Com muitas instâncias distribuídas, elas mostram rápido qual dependência e qual região estão degradadas.

Para aprofundar o lado das métricas, veja como criar e consumir métricas com System.Diagnostics.Metrics.

Recarga dinâmica do Polly no .NET

Os parâmetros de resiliência raramente acertam de primeira. O número ideal de retentativas e a duração de abertura só aparecem sob tráfego real.

Felizmente, o Polly no .NET permite recarregar a configuração em tempo de execução com o EnableReloads.

httpClientBuilder.AddResilienceHandler(
    "AdvancedPipeline",
    static (ResiliencePipelineBuilder<HttpResponseMessage> builder,
        ResilienceHandlerContext context) =>
    {
        // Recarrega o pipeline quando as opções nomeadas mudarem
        context.EnableReloads<HttpRetryStrategyOptions>("RetryOptions");

        var retryOptions =
            context.GetOptions<HttpRetryStrategyOptions>("RetryOptions");

        builder.AddRetry(retryOptions);
    });

Com as opções ligadas a uma seção de configuração, mudanças no appsettings.json valem sem reinicialização. Durante um incidente, aumentar o BreakDuration pode ser a diferença entre conter e propagar a falha.

Armadilhas conhecidas do Polly no .NET

Antes de levar o Polly no .NET para produção, algumas incompatibilidades documentadas merecem atenção.

Ao combinar retry e timeout, lembre que o Polly lança TimeoutRejectedException. Essa exceção herda de Exception, e não da TimeoutException do .NET, o que quebra predicados escritos com a suposição contrária.

Em aplicações gRPC, o Grpc.Net.ClientFactory até a versão 2.63.0 gera exceção ao habilitar os handlers padrão. A solução é atualizar para 2.64.0 ou superior.

Com o Application Insights até a versão 2.22.0, registrar a resiliência antes da telemetria faz a telemetria sumir. Nesse caso, atualize para 2.23.0 ou registre o Application Insights primeiro.

Conclusão

Retentativa, disjuntor e tempo limite não são recursos independentes. Na verdade, são respostas a três modos de falha que, juntos, cobrem os problemas reais de integrações HTTP.

A retentativa trata a falha breve e autocorretiva. Já o disjuntor trata a indisponibilidade prolongada e protege a dependência e a própria aplicação.

O tempo limite, por fim, trata a ausência de resposta. Sem os três, a cobertura fica parcial.

A recomendação prática para usar Polly no .NET é começar pelo AddStandardResilienceHandler. Seus valores padrão resolvem a maioria dos casos sem decisões arbitrárias.

A configuração manual entra quando a dependência exige ajuste. Quando esse momento chegar, a telemetria instrumentada dirá quais números mudar.

FAQ

Qual pacote devo usar em um projeto novo?

Use Microsoft.Extensions.Http.Resilience para clientes HTTP. O pacote Microsoft.Extensions.Http.Polly está descontinuado.

Preciso instalar o Polly separadamente?

Não. O pacote Microsoft.Extensions.Http.Resilience já traz o Polly como dependência e expõe a integração com o HttpClient.

Quantas retentativas são adequadas?

Três é um valor razoável e corresponde ao padrão do handler oficial. Números altos aumentam a latência sem ganho proporcional.

Posso usar retentativa em requisições POST?

Somente se a operação for idempotente ou aceitar chave de idempotência. Caso contrário, use DisableForUnsafeHttpMethods.

O que acontece enquanto o circuito está aberto?

As requisições falham na hora com BrokenCircuitException, sem chamar a dependência.

Devo empilhar vários handlers no mesmo cliente?

Não. A orientação oficial é usar um único handler por cliente e compor as estratégias com AddResilienceHandler.

Resiliência substitui monitoramento?

Não. Ela mascara falhas transitórias, o que torna o monitoramento ainda mais necessário.