EF Core Connection Resiliency and Retry Logic

EF Core Connection Resiliency and Retry Logic

5 min read··

clouddotnetef-core

EF Core connection resiliency is the built-in retry logic you enable with EnableRetryOnFailure in the provider options, which installs an execution strategy that retries transient failures with exponential backoff. Short network interruptions, failovers, and throttling are temporary, so a retry usually succeeds. Explicit transactions are the exception: wrap them in CreateExecutionStrategy so a retry cannot replay half the work.

A database operation can fail even when the query and data are valid. EF Core execution strategies handle the retry loop when you define the correct transactional boundary.

Transient Failures Are Normal

In cloud environments, database connections fail. Load balancers rotate. SQL Azure throttles requests. Network hiccups happen. These are transient failures - they succeed if you retry.

Without retry logic, a brief network blip causes 500 errors for your users. EF Core's execution strategy solves this by automatically retrying failed operations.

Enabling Retry Logic

SQL Server / Azure SQL

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorNumbersToAdd: null);
    });
});

PostgreSQL (Npgsql)

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseNpgsql(connectionString, npgsqlOptions =>
    {
        npgsqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorCodesToAdd: null);
    });
});

Both use exponential backoff with jitter by default.

What Gets Retried?

EF Core retries these operations:

  • SaveChangesAsync()
  • ToListAsync(), FirstOrDefaultAsync(), etc.
  • Any LINQ query execution

It retries only transient SQL errors. Constraint violations, syntax errors, and other non-transient failures are not retried.

SQL Server Transient Error Numbers

The default transient error list for SQL Server includes, among others:

  • -2: Timeout
  • 20: Instance error
  • 64: Connection error
  • 233: Connection closed
  • 10053: Transport error
  • 10054: Connection reset
  • 10060: Connection timeout
  • 40143: Throttled (Azure SQL)
  • 40197: Service error (Azure SQL)
  • 40501: Service busy (Azure SQL)
  • 40613: Database unavailable (Azure SQL)
  • 49918: Not enough resources (Azure SQL)
  • 49919: Too many requests (Azure SQL)
  • 49920: Too many requests (Azure SQL)

You can add custom error numbers:

sqlOptions.EnableRetryOnFailure(
    maxRetryCount: 5,
    maxRetryDelay: TimeSpan.FromSeconds(30),
    errorNumbersToAdd: [4060, 18401]);

The Transaction Problem

Here's the critical pitfall. Retries don't work with manual transactions:

// ❌ This throws InvalidOperationException with retry enabled
using var transaction = await _db.Database.BeginTransactionAsync();

order.Status = OrderStatus.Confirmed;
await _db.SaveChangesAsync();

payment.Status = PaymentStatus.Captured;
await _db.SaveChangesAsync();

await transaction.CommitAsync();

Why? If the first SaveChangesAsync succeeds but the second fails, EF Core can't retry the second without replaying the first. The retry strategy doesn't know about your transaction boundaries.

The Fix: CreateExecutionStrategy

var strategy = _db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    using var transaction = await _db.Database.BeginTransactionAsync();

    order.Status = OrderStatus.Confirmed;
    await _db.SaveChangesAsync();

    payment.Status = PaymentStatus.Captured;
    await _db.SaveChangesAsync();

    await transaction.CommitAsync();
});

ExecuteAsync wraps the entire operation - including the transaction - as a single retriable unit. If any step fails with a transient error, the whole block is retried from the beginning.

The execution strategy wraps a begin-transaction, SaveChanges, commit block: a transient error retries the whole block with exponential backoff, while success exits

Custom Execution Strategy

For fine-grained control, create a custom strategy:

public class CustomRetryStrategy : SqlServerRetryingExecutionStrategy
{
    private readonly ILogger<CustomRetryStrategy> _logger;

    public CustomRetryStrategy(
        ExecutionStrategyDependencies dependencies,
        int maxRetryCount,
        TimeSpan maxRetryDelay,
        ILogger<CustomRetryStrategy> logger)
        : base(dependencies, maxRetryCount, maxRetryDelay, null)
    {
        _logger = logger;
    }

    protected override bool ShouldRetryOn(Exception exception)
    {
        var shouldRetry = base.ShouldRetryOn(exception);

        if (shouldRetry)
        {
            _logger.LogWarning(exception,
                "Transient database error. Retrying...");
        }

        return shouldRetry;
    }

    protected override TimeSpan? GetNextDelay(Exception lastException)
    {
        var delay = base.GetNextDelay(lastException);

        if (delay.HasValue)
        {
            _logger.LogWarning(
                "Retrying in {Delay}ms after error: {Message}",
                delay.Value.TotalMilliseconds,
                lastException.Message);
        }

        return delay;
    }
}

Register it (note the AddDbContext overload that exposes the service provider, so we can resolve the logger):

builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.ExecutionStrategy(dependencies =>
            new CustomRetryStrategy(
                dependencies,
                maxRetryCount: 5,
                maxRetryDelay: TimeSpan.FromSeconds(30),
                sp.GetRequiredService<ILogger<CustomRetryStrategy>>()));
    });
});

Retry With Polly

For more advanced policies, combine EF Core with Polly. With Polly v8, that means a resilience pipeline:

var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder()
            .Handle<SqlException>(ex => ex.IsTransient)
            .Handle<TimeoutException>(),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        Delay = TimeSpan.FromSeconds(1),
        OnRetry = args =>
        {
            logger.LogWarning(
                "Retry {Attempt} after {Delay}ms: {Error}",
                args.AttemptNumber,
                args.RetryDelay.TotalMilliseconds,
                args.Outcome.Exception?.Message);

            return ValueTask.CompletedTask;
        }
    })
    .Build();

await pipeline.ExecuteAsync(async ct =>
{
    await _db.SaveChangesAsync(ct);
});

But in most cases, EF Core's built-in retry is sufficient - it already knows which provider errors are transient. Use Polly when you need circuit breakers or more complex resilience patterns, and avoid stacking Polly retries on top of EnableRetryOnFailure (you'd multiply the attempts).

Idempotency Matters

Retries mean your operation might execute more than once. Make sure your operations are idempotent.

Here's the subtle failure mode: the INSERT commits on the server, but the connection drops before the acknowledgment reaches your app. EF Core sees a transient error and retries. With database-generated keys, you get a duplicate row; with a client-generated key, the retry fails on the primary key violation.

// ❌ Not idempotent - a retry can re-execute an insert that already committed
var order = new Order { Id = Guid.NewGuid(), Total = 100 };
_db.Orders.Add(order);
await _db.SaveChangesAsync();

// ✅ Idempotent - upsert pattern
var order = await _db.Orders.FindAsync(orderId);
if (order is null)
{
    order = new Order { Id = orderId, Total = 100 };
    _db.Orders.Add(order);
}
else
{
    order.Total = 100;
}
await _db.SaveChangesAsync();

Or use the Inbox Pattern for message consumers.

Health Check Integration

Monitor connection health alongside retry:

builder.Services.AddHealthChecks()
    .AddSqlServer(
        connectionString,
        name: "sql-server",
        timeout: TimeSpan.FromSeconds(5),
        tags: ["db", "ready"]);

Summary

Enable the provider's retry strategy for transient database failures. When you open an explicit transaction, execute the entire transaction through CreateExecutionStrategy so a retry cannot replay only half the work. Assume the final commit can be ambiguous and give externally visible operations an idempotency strategy.

Frequently Asked Questions

How do I enable retry logic in EF Core?

Call EnableRetryOnFailure in the provider options when configuring the DbContext, for example inside UseSqlServer or UseNpgsql. This installs a retrying execution strategy with exponential backoff for transient errors.

What errors does EnableRetryOnFailure retry?

Only errors the provider classifies as transient, such as timeouts, dropped connections, and Azure SQL throttling codes. Constraint violations, syntax errors, and other deterministic failures are never retried.

Why does BeginTransaction throw when retry on failure is enabled?

The execution strategy cannot safely retry part of a user-defined transaction. Wrap the whole transaction in Database.CreateExecutionStrategy().ExecuteAsync so the entire block retries as one unit.

Do I need Polly if EF Core has built-in retries?

Usually not for database calls. EF Core execution strategies understand provider-specific transient errors. Add Polly when you need capabilities EF Core lacks, like circuit breakers across your data layer.

Are retried EF Core operations safe to run twice?

Not automatically. A retry can re-execute an insert whose first attempt actually committed. Design operations to be idempotent, for example with client-generated keys and upsert logic.

Loading comments...

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.