Retry Pattern in .NET With Polly

aspnetcoredistributed-systemsdotnetpolly
Retry Pattern in .NET With Polly

The retry pattern repeats an operation after a transient failure, with a maximum attempt count and a delay between attempts. In .NET, Polly provides backoff, jitter, and failure predicates; the HTTP resilience package integrates these with HttpClient. Only retry when another attempt can succeed and repeating the operation is safe.

Why Retry?

In distributed systems, transient failures are common:

  • A database connection times out momentarily
  • An HTTP call fails because the target server is restarting
  • A message broker is temporarily unreachable

These failures are temporary. Retrying after a short delay usually succeeds.

The retry pattern automatically re-executes a failed operation, with configurable delays and limits, so your application recovers from transient issues without manual intervention.

Polly in .NET

Polly provides retry and other resilience strategies. Microsoft.Extensions.Resilience supplies dependency-injection integration, and Microsoft.Extensions.Http.Resilience adds HTTP-specific strategies and handlers.

Install the packages:

dotnet add package Microsoft.Extensions.Resilience
dotnet add package Microsoft.Extensions.Http.Resilience

Basic Retry

Create an ASP.NET Core project with dotnet new web -n RetryDemo and install the packages above. In Program.cs, retry only selected HTTP transport and status failures:

using System.Net;
using Polly;
using Polly.Registry;
using Polly.Retry;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResiliencePipeline("orders", pipelineBuilder =>
{
    pipelineBuilder.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromMilliseconds(500),
        ShouldHandle = new PredicateBuilder()
            .Handle<HttpRequestException>(TransientHttp.IsRetryable)
    });
});

builder.Services.AddHttpClient<OrderService>(client =>
    client.BaseAddress = new Uri("https://orders.example.com"));

var app = builder.Build();
app.MapGet("/orders/{id:guid}",
    (Guid id, OrderService orders, CancellationToken ct) =>
        orders.GetOrderAsync(id, ct));
app.Run();

public static class TransientHttp
{
    public static bool IsRetryable(HttpRequestException exception) =>
        exception.StatusCode is null or HttpStatusCode.RequestTimeout
            or HttpStatusCode.TooManyRequests ||
        (int)exception.StatusCode.Value >= 500;
}

Add OrderService.cs to resolve the shared pipeline and the factory-managed client:

using System.Net.Http.Json;
using Polly;
using Polly.Registry;

public sealed record Order(Guid Id, decimal Total);

public sealed class OrderService
{
    private readonly ResiliencePipeline _pipeline;
    private readonly HttpClient _httpClient;

    public OrderService(HttpClient httpClient,
        ResiliencePipelineProvider<string> pipelineProvider)
    {
        _pipeline = pipelineProvider.GetPipeline("orders");
        _httpClient = httpClient;
    }

    public async Task<Order> GetOrderAsync(Guid orderId, CancellationToken ct)
    {
        return await _pipeline.ExecuteAsync(async token =>
        {
            return await _httpClient.GetFromJsonAsync<Order>(
                $"/api/orders/{orderId}", token)
                ?? throw new InvalidOperationException("The order response was empty.");
        }, ct);
    }
}

MaxRetryAttempts = 3 permits three retries after the original call: four attempts total. The predicate excludes permanent HTTP errors, JSON errors, and caller cancellation. The basic example does not retry HttpClient timeouts; the HTTP resilience handler below adds a separate attempt timeout and handles it explicitly.

Retry With Exponential Backoff

Fixed delays can overwhelm a struggling service. Exponential backoff increases the delay between each retry:

pipelineBuilder.AddRetry(new RetryStrategyOptions
{
    MaxRetryAttempts = 3,
    BackoffType = DelayBackoffType.Exponential,
    Delay = TimeSpan.FromSeconds(1),
    UseJitter = true,
    ShouldHandle = new PredicateBuilder()
        .Handle<HttpRequestException>(TransientHttp.IsRetryable)
});

Without jitter, these retry delays would be 1, 2, and 4 seconds. With exponential jitter enabled, Polly uses a decorrelated sequence, so individual delays can be shorter or longer and need not increase monotonically. The retry documentation describes the delay calculation.

Retry decision loop: return a successful result, retry a transient failure after backoff and jitter while attempts remain, or return the failure when retries are unsuitable or exhausted

Retry for HTTP Clients

The most common use case is retrying HTTP calls with IHttpClientFactory:

builder.Services.AddHttpClient("OrdersApi", client =>
{
    client.BaseAddress = new Uri("https://orders.example.com");
})
.AddStandardResilienceHandler(options =>
    options.Retry.DisableForUnsafeHttpMethods());

AddStandardResilienceHandler() adds a pre-configured resilience pipeline that includes:

  • Retry: 3 retries with exponential backoff and jitter
  • Circuit breaker: stops calling a failing service
  • Attempt timeout: limits each network attempt
  • Concurrency limiter: bounds simultaneous executions for this handler, rather than requests per minute across the fleet
  • Total timeout: overall time limit for all attempts

You can also override the default resilience handlers when the standard settings don't fit. Add using Microsoft.Extensions.Http.Resilience; for these HTTP-specific APIs. The standard handler retries every HTTP method by default; DisableForUnsafeHttpMethods() disables retries for POST, PATCH, PUT, DELETE, and CONNECT. Use this handler instead of the manually wrapped OrderService pipeline above, so requests are not retried at both layers.

For custom configuration:

builder.Services.AddHttpClient("OrdersApi", client =>
{
    client.BaseAddress = new Uri("https://orders.example.com");
})
.AddResilienceHandler("custom", pipeline =>
{
    pipeline.AddTimeout(TimeSpan.FromSeconds(20));
    var retry = new HttpRetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        Delay = TimeSpan.FromSeconds(1),
        UseJitter = true
    };
    retry.DisableForUnsafeHttpMethods();
    pipeline.AddRetry(retry);
    pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});

The HTTP defaults handle 408, 429, 5xx, HttpRequestException, and Polly's TimeoutRejectedException. HttpRetryStrategyOptions also honors Retry-After when supplied. The outer timeout caps all attempts and delays together; the inner timeout caps each attempt.

Retry With Circuit Breaker

Retrying a service that's completely down wastes time and resources. Combine retry with a circuit breaker:

// Add using Polly.CircuitBreaker; and using Polly.Timeout;
builder.Services.AddResiliencePipeline("external-api", pipeline =>
{
    pipeline
        .AddRetry(new RetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
            Delay = TimeSpan.FromMilliseconds(500),
            UseJitter = true,
            ShouldHandle = new PredicateBuilder()
                .Handle<HttpRequestException>(TransientHttp.IsRetryable)
                .Handle<TimeoutRejectedException>()
        })
        .AddCircuitBreaker(new CircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,               // Open after 50% failure rate
            SamplingDuration = TimeSpan.FromSeconds(30),
            MinimumThroughput = 10,            // Need at least 10 calls in window
            BreakDuration = TimeSpan.FromSeconds(30),
            ShouldHandle = new PredicateBuilder()
                .Handle<HttpRequestException>(TransientHttp.IsRetryable)
                .Handle<TimeoutRejectedException>()
        });
});

The circuit breaker tracks the failure rate. If too many calls fail, it "opens" and immediately rejects subsequent calls without attempting them, so the downstream service gets time to recover. The retry predicate excludes BrokenCircuitException, so an open circuit ends the operation immediately. Reuse the registered pipeline across calls; creating a new breaker for every request discards its failure history.

A local .NET 10 check used a callback that throws, zero retry delay, and a two-call breaker threshold:

Polly: three retries produce four attempts; 404 and cancellation are not retried; open breaker stops after two failing calls.

What to Retry (and What Not To)

Candidates for retry, when the operation is safe to repeat:

  • Network timeouts
  • HTTP 429 (rate limited) and 5xx (server errors)
  • Database connection failures
  • Message broker connection issues

Don't retry these (permanent failures):

  • HTTP 400 (bad request): your data is wrong, so retrying won't fix it
  • HTTP 401/403 (authentication/authorization): your credentials won't become valid on the next attempt
  • HTTP 404 (not found): the resource doesn't exist
  • Validation errors
  • Business rule violations

Status codes alone do not prove a failure is temporary. For example, a 501 response may indicate a permanently unsupported operation; tailor the policy to the dependency's documented contract.

Retries and Idempotency

A timeout leaves the outcome uncertain. The server might have processed the request successfully and the response got lost.

Now imagine that request was POST /api/payments. Your retry just charged the customer twice.

Only retry operations that are idempotent (safe to execute multiple times).

  • GET, PUT, and DELETE are idempotent by convention
  • POST is not, unless you make it so

The standard fix is an idempotency key: the client sends a unique key with the request, and the server returns the stored result if it has already processed that key. Reuse the same key across attempts, and require server-side deduplication to be atomic with the side effect. The server's key retention and payload-matching rules determine how long retrying remains safe.

Retry Amplification

Retries also multiply across layers.

Say your API retries 3 times, it calls a service that retries 3 times, and that service calls a database client that retries 3 times. A single user request can turn into 64 database attempts: four attempts per layer, multiplied across three layers. Three total attempts per layer would produce 27. When the database is struggling, this retry storm makes the outage worse.

Two guidelines:

  • Retry at one layer, ideally the outermost one that has enough context to decide
  • Keep retry counts low, distinguish retries from total attempts, and cap the overall execution time

This is part of a broader set of resilience patterns for distributed systems that work best in combination.

Handling Retry in EF Core

EF Core has built-in retry logic for database providers:

Install Npgsql.EntityFrameworkCore.PostgreSQL, import Microsoft.EntityFrameworkCore, and use your application's AppDbContext and connection string:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql =>
        npgsql.EnableRetryOnFailure(
            maxRetryCount: 3,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorCodesToAdd: null)));

This handles transient PostgreSQL errors automatically. You don't need Polly for EF Core database calls; use the built-in connection resiliency.

EF Core's execution strategy wraps each SaveChangesAsync call. If you need to retry a larger operation (multiple SaveChanges calls in a transaction), use an explicit execution strategy:

// Create a fresh context for each replay through IDbContextFactory<AppDbContext>.
await using var strategyContext = await dbContextFactory.CreateDbContextAsync(ct);
var strategy = strategyContext.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async token =>
{
    await using var db = await dbContextFactory.CreateDbContextAsync(token);
    await using var transaction = await db.Database.BeginTransactionAsync(token);

    // Recreate your database-only operation here on every attempt.
    await db.SaveChangesAsync(token);

    await transaction.CommitAsync(token);
}, ct);

Register the factory with AddDbContextFactory<AppDbContext> and the same provider retry options. This shows the transaction boundary; insert your actual database mutations inside the delegate. Do not send email or make payment calls there, and account for an uncertain commit outcome with stable operation IDs or a verification step.

Logging Retries

Always log retries so you can detect patterns:

pipeline.AddRetry(new RetryStrategyOptions
{
    MaxRetryAttempts = 3,
    BackoffType = DelayBackoffType.Exponential,
    Delay = TimeSpan.FromSeconds(1),
    ShouldHandle = new PredicateBuilder()
        .Handle<HttpRequestException>(TransientHttp.IsRetryable),
    OnRetry = args =>
    {
        logger.LogWarning(
            "Retry {Attempt} after {Delay}ms. Reason: {Reason}",
            args.AttemptNumber + 1,
            args.RetryDelay.TotalMilliseconds,
            args.Outcome.Exception?.Message ?? "Non-success result");

        return ValueTask.CompletedTask;
    }
});

If you see frequent retries in your logs, investigate the root cause rather than just increasing retry counts.

Summary

Retry only transient failures and only when the operation is idempotent or protected by an idempotency key. Use exponential backoff with jitter, keep the retry budget at one layer, and let a circuit breaker stop pressure on a dependency that is not recovering. AddStandardResilienceHandler() is a sensible HTTP baseline, but its events still need monitoring because frequent retries are a dependency-health signal.

Frequently Asked Questions

What is the retry pattern?

The retry pattern automatically re-executes a failed operation after a short delay, on the assumption that the failure is transient. It is typically combined with exponential backoff, jitter, and a maximum attempt count.

Should I use Polly directly or Microsoft.Extensions.Resilience?

Microsoft.Extensions.Resilience is built on Polly v8 and integrates with dependency injection, so it is the recommended entry point in .NET 8 and later. You are still writing Polly strategies either way.

Which HTTP status codes should trigger a retry?

The standard HTTP resilience handler considers 408, 429, 5xx, network failures, and attempt timeouts retryable. Tailor that policy to the dependency and retry only operations that are safe to repeat. Most 400, 401, 403, and 404 responses need a changed request rather than an automatic retry.

Is it safe to retry a POST request?

Only if the operation is idempotent. If the first attempt actually succeeded but the response was lost, a retry can create a duplicate order or double charge. Use idempotency keys to make retries of POST requests safe.

Do I need Polly for EF Core database retries?

No. EF Core has built-in connection resiliency via EnableRetryOnFailure, which understands provider-specific transient error codes. Use that instead of wrapping database calls in Polly.

  • Circuit Breaker Pattern in .NET

    A circuit breaker temporarily blocks calls after a dependency exceeds a configured failure threshold. This .NET guide configures Polly and HttpClient, shows where retries and timeouts belong, and handles an open circuit without inventing a successful result.

  • Distributed Rate Limiting With Redis in .NET

    In-process rate limits apply separately to each application instance. A Redis sliding-window limiter shares admission state across the fleet and uses Lua to count and admit requests atomically. This .NET example uses server time, returns Retry-After on 429 responses, and defines what happens when Redis is unavailable.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.