blog
Ottorino Bruni  

How to Build Resilient APIs in ASP.NET Core with Polly Retry and Circuit Breaker

Introduction

When building modern APIs, one thing is certain: HTTP calls will fail.

It does not matter how well your code is written. Networks are unreliable, external services can be slow, and temporary errors happen all the time. A request might timeout, return a 500 error, or simply fail because of a short network interruption.

A common reaction is to add a retry.
“If it fails, just try again.”

At first, this sounds like a good solution. And in many cases, it is. But retry alone is not enough. In fact, if implemented incorrectly, it can make things worse. Multiple retries from many clients can overload a service that is already struggling, creating a cascade of failures instead of solving the problem.

This is where resilience comes into play.

In this article, we will see how to build resilient APIs in ASP.NET Core using Polly, focusing on two key strategies:

  • Retry with exponential backoff
  • Circuit Breaker

We will also use the latest approach introduced in Polly v8, based on resilience pipelines, and walk through a practical example you can use in your own projects.

The Problem with Naive Retries

Retrying a failed HTTP call often feels like the simplest solution. When something goes wrong, trying again seems reasonable and sometimes it works.

The problem appears in real-world scenarios. If a service becomes slow or starts failing, many requests can fail at the same time. When every client retries, the number of requests increases instead of decreasing, putting even more pressure on the system and making recovery harder.

Another common mistake is treating all errors the same way. Some failures are temporary, like timeouts or network issues, but others, such as invalid input or bad requests, will never succeed. Retrying in those cases only adds unnecessary load.

There is also a hidden risk. If a request is processed successfully but the response is lost, a retry can execute the same operation again, leading to duplicated actions.

Retry is useful, but it must be used carefully. On its own, it does not make a system resilient and can even make things worse.

What is Polly and Why It Matters

When dealing with unreliable HTTP calls, writing custom retry logic quickly becomes messy and hard to maintain. You might start with a simple retry, then add delays, then handle specific exceptions, and before you know it, the code becomes complex and difficult to reason about.

This is where Polly comes in.

Polly is a .NET library designed to help you build resilient applications by handling transient faults in a clean and structured way. Instead of writing manual logic, you define strategies that describe how your application should behave when something goes wrong.

These strategies include:

  • Retry
  • Circuit Breaker
  • Timeout
  • Fallback

With the latest version, Polly v8, the approach has evolved into what is called a resilience pipeline. Instead of combining separate policies, you define a pipeline of strategies that work together in a consistent and predictable way.

In ASP.NET Core, Polly integrates naturally with HttpClient, allowing you to apply these strategies to outgoing HTTP calls without cluttering your business logic.

This makes your code cleaner, easier to maintain, and more robust when dealing with real-world failures.

Setting Up Polly v8 in ASP.NET Core

With Polly v8, the recommended way to handle resilience in ASP.NET Core is through the integration with HttpClient and the new resilience pipeline model.

Instead of manually wrapping calls, you configure everything at startup and let the framework handle it for you.

First, install the required package:

dotnet add package Microsoft.Extensions.Http.Resilience

This package provides a modern integration built on top of Polly v8.

Now, you can configure a resilient HTTP client in Program.cs:

builder.Services.AddHttpClient("my-api")
    .AddResilienceHandler("my-pipeline", builder =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromSeconds(1),
            BackoffType = DelayBackoffType.Exponential
        });

        builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(10),
            MinimumThroughput = 5,
            BreakDuration = TimeSpan.FromSeconds(5)
        });
    });

This configuration defines a resilience pipeline with two strategies:

  • A retry strategy that retries failed requests up to 3 times using exponential backoff
  • A circuit breaker that stops calls temporarily when too many failures occur

Once configured, you can use this client anywhere in your application:

public class MyService
{
    private readonly HttpClient _httpClient;

    public MyService(IHttpClientFactory factory)
    {
        _httpClient = factory.CreateClient("my-api");
    }

    public async Task<string> GetDataAsync()
    {
        var response = await _httpClient.GetAsync("/data");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

The important thing to understand is that your business logic stays clean.
All resilience concerns are handled by the pipeline you configured earlier.

Implementing Retry the Right Way

How to Build Resilient APIs in ASP.NET Core – Retry + Exponential Backoff

Retry can be very helpful, but only when it is configured with care.

The main idea is simple: if a request fails because of a temporary problem, trying again after a short delay may solve the issue. This is common with timeouts, short network interruptions, or brief service instability.

However, retry should not be applied blindly. Repeating the same request immediately and multiple times can put even more pressure on a service that is already struggling. That is why a small delay between attempts is important.

A common approach is exponential backoff. Instead of retrying every time after the same interval, the delay increases with each attempt. This gives the downstream service a little more time to recover and reduces the risk of creating unnecessary traffic.

In the previous example, we configured Polly with three retry attempts and exponential backoff:

builder.AddRetry(new HttpRetryStrategyOptions
{
    MaxRetryAttempts = 3,
    Delay = TimeSpan.FromSeconds(1),
    BackoffType = DelayBackoffType.Exponential
});

This means the client will wait a little longer after each failed attempt instead of retrying immediately.

Another important point is that not every error should be retried. Temporary failures may recover, but permanent errors such as invalid input or bad requests will not. Retrying those cases only wastes resources and makes the system noisier.

Used correctly, retry can improve reliability. Used without rules, it can easily become part of the problem.

Understanding the Circuit Breaker Pattern

How to Build Resilient APIs in ASP.NET Core – Retry + Circuit Breaker

While retry helps handle temporary failures, it does not solve the problem when a service is consistently failing.

If a downstream service keeps returning errors, continuing to send requests, even with retries, only makes things worse. This is where the circuit breaker pattern becomes essential.

The idea is simple. After a certain number of failures, the system stops sending requests to the failing service for a period of time. Instead of waiting for more failures, it immediately returns an error and gives the service time to recover.

The circuit breaker has three main states:

  • Closed → everything works normally, requests are allowed
  • Open → too many failures occurred, requests are blocked
  • Half-Open → a small number of requests are allowed to test if the service has recovered

In Polly, this behavior can be configured as part of the resilience pipeline:

builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
    FailureRatio = 0.5,
    SamplingDuration = TimeSpan.FromSeconds(10),
    MinimumThroughput = 5,
    BreakDuration = TimeSpan.FromSeconds(5)
});

In this example, if enough requests fail within a short period of time, the circuit will open and stop further calls for a few seconds.

The key benefit is that your application avoids wasting resources on a service that is already down, and it prevents the system from being overwhelmed.

When the break period ends, the circuit allows a few test requests. If they succeed, normal operation resumes. If they fail again, the circuit opens once more.

Combined with retry, the circuit breaker helps create a more stable and predictable system, especially under heavy load or partial outages.

Combining Retry and Circuit Breaker

Retry and circuit breaker are often used together, but they solve different problems.

Retry is useful when failures are temporary. It gives the request another chance to succeed after a short delay. Circuit breaker, instead, is useful when failures keep happening and the service clearly needs time to recover.

This combination works well because retry handles small and short-lived issues, while circuit breaker protects the system from repeated failures over time.

In practice, the flow is straightforward. A request may fail once or twice and be retried. If failures continue and reach the configured threshold, the circuit breaker opens and blocks further calls for a while. This prevents the application from continuously sending requests to a service that is already in trouble.

Used together, these two strategies help balance resilience and protection. Retry improves reliability for transient problems, while circuit breaker avoids making a bad situation worse.

The important point is that they are complementary. Retry alone can increase traffic during an outage, while circuit breaker alone may be too aggressive for short and recoverable issues. Together, they provide a more robust way to handle HTTP failures in real applications.

Common Mistakes to Avoid

Even if retry and circuit breaker are easy to configure, using them without clear rules can still create problems.

One common mistake is retrying every failure. Not all errors are temporary, and retrying a bad request or a validation error will never change the outcome. It only adds noise and unnecessary traffic.

Another mistake is using retries without any delay. Immediate retries can hit a failing service again and again without giving it time to recover. This increases load exactly when the system is already under pressure.

It is also important to avoid too many retry attempts. A small number of retries may help with transient issues, but too many attempts can slow down the application and amplify failures instead of reducing them.

Finally, retrying operations that are not safe to repeat can be dangerous. If the same request creates data, triggers a payment, or sends an email, repeating it may cause duplicate actions unless the operation is designed to handle that safely.

These are some of the reasons why resilience should be configured carefully. The goal is not just to retry failed calls, but to do it in a way that protects both your application and the services it depends on.

Practical Example: Building a Resilient HTTP Client in ASP.NET Core

Disclaimer: This example is purely for educational purposes. There are better ways to write code and applications that can optimize this example. Use this as a starting point for learning, but always strive to follow best practices and improve your implementation.

A common challenge in modern applications is dealing with unreliable HTTP calls. External services may become slow, temporarily unavailable, or return intermittent errors that are outside of your control.

In this example, we will build a simple ASP.NET Core application that uses HttpClient together with Polly v8 to handle transient failures in a more resilient way.

We will configure a resilience pipeline with two strategies:

  • Retry
  • Circuit Breaker

This will allow our application to retry temporary failures and stop sending requests for a short time when a downstream service keeps failing.

The goal of this example is to show how to configure these strategies in a clean way and how they can help make HTTP communication more robust.

Prerequisites

Before starting, make sure you have the following installed:

  • .NET SDK: Download and install the .NET SDK if you haven’t already.
  • Visual Studio Code (VSCode): Install Visual Studio Code for a lightweight code editor.
  • C# Dev Kit for VSCode: Install the C# Dev Kit for VSCode to enable C# support.

Verify your installation:

dotnet --version

Step 1 – Create the Project

Open your terminal and create a new ASP.NET Core Web API project:

dotnet new webapi -n ResilientApiDemo
cd ResilientApiDemo

Step 2 – Install the Required Package

Now we need to install the package that provides the resilience integration for HttpClient in ASP.NET Core.

Run the following command in your terminal:

dotnet add package Microsoft.Extensions.Http.Resilience

This package is built on top of Polly v8 and gives us a modern way to configure retry, circuit breaker, timeout, and other resilience strategies.

Step 3 – Create Local Endpoints to Simulate an External API

For this example, instead of calling a real external service, we will create a few local endpoints inside the same application.

This approach is better for learning because it avoids external dependencies, SSL issues, and network problems that are unrelated to Polly itself. It also makes the example easier to test and reproduce.

Add the following endpoints in Program.cs before app.Run();:

app.MapGet("/external-api/success", () =>
{
    return Results.Ok("External API success");
});

app.MapGet("/external-api/fail", () =>
{
    return Results.StatusCode(500);
});

app.MapGet("/external-api/slow", async () =>
{
    await Task.Delay(5000);
    return Results.Ok("Slow response");
});

These endpoints simulate three common scenarios:

  • a successful response
  • a failing response
  • a slow response

This gives us a simple way to test retry and circuit breaker behavior locally.

Step 4 – Configure the HttpClient Base Address

Now update the HttpClient registration so that it points to the same application.

In Program.cs, configure the client like this:

builder.Services.AddHttpClient("external-api", client =>
{
    client.BaseAddress = new Uri("http://localhost:5210");
})
.AddResilienceHandler("resilient-pipeline", builder =>
{
    builder.AddRetry(new HttpRetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential
    });

    builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(10),
        MinimumThroughput = 5,
        BreakDuration = TimeSpan.FromSeconds(5)
    });
});
How to Build Resilient APIs in ASP.NET Core with Polly – Program.cs

With this setup, the named HttpClient will send requests to your local test endpoints.

Step 5 – Create a Service That Uses the HttpClient

Now let’s create a simple service that uses the configured client.

Create a new file called ExternalApiService.cs and add the following code:

public class ExternalApiService
{
    private readonly HttpClient _httpClient;

    public ExternalApiService(IHttpClientFactory factory)
    {
        _httpClient = factory.CreateClient("external-api");
    }

    public async Task<string> GetFailingDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/fail");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetSuccessfulDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/success");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetSlowDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/slow");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

This service calls the local endpoints we created earlier, allowing us to test different scenarios in a controlled way.

Step 6 – Register the Service

Go back to Program.cs and register the service in the dependency injection container:

builder.Services.AddScoped<ExternalApiService>();

Step 7 – Expose Minimal API Endpoints for Testing

Now let’s add a simple endpoint that uses our service.

In Program.cs, before app.Run();, add:

app.MapGet("/test/fail", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetFailingDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

app.MapGet("/test/success", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetSuccessfulDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

app.MapGet("/test/slow", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetSlowDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

These endpoints make it easy to test the resilience pipeline from your browser, Postman, or the .http file included in the project.

How to Build Resilient APIs in ASP.NET Core with Polly – External Service

Step 8 – How to Update the .http File

To make testing easier, you can use the .http file included in the project.

Update it with the following requests:

@ResilientApiDemo_HostAddress = http://localhost:5210

### Test success scenario
GET {{ResilientApiDemo_HostAddress}}/test/success
Accept: application/json

###

### Test failing scenario (triggers retry and circuit breaker)
GET {{ResilientApiDemo_HostAddress}}/test/fail
Accept: application/json

###

### Test slow scenario (useful for latency testing)
GET {{ResilientApiDemo_HostAddress}}/test/slow
Accept: application/json

###

Step 9 – Run the Application

Start the application with:

dotnet run

Then test the endpoints.

For example:

  • http://localhost:5210/test/success
  • http://localhost:5210/test/fail
  • http://localhost:5210/test/slow
Screenshot

When calling /test/fail, Polly will retry the request based on the configured retry strategy.

If you call the failing endpoint multiple times in a row, you will eventually see a response like this:

The circuit is now open and is not allowing calls.

This means the circuit breaker has been triggered.

At this point, the application stops sending requests to the downstream service and immediately returns an error. This is known as fail fast behavior and is designed to protect your system from repeated failures.

After a short period of time (based on the configured break duration), the circuit breaker will allow a few requests again to check if the service has recovered.

Full Example Code

For convenience, here is the complete example in a single file.

This includes:

  • local endpoints that simulate an external API
  • HttpClient configuration with Polly
  • retry and circuit breaker
  • service class
  • minimal API endpoints for testing
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("external-api", client =>
{
    client.BaseAddress = new Uri("http://localhost:5210");
})
.AddResilienceHandler("resilient-pipeline", builder =>
{
    builder.AddRetry(new HttpRetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential
    });

    builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(10),
        MinimumThroughput = 5,
        BreakDuration = TimeSpan.FromSeconds(5)
    });
});

builder.Services.AddScoped<ExternalApiService>();

var app = builder.Build();

app.MapGet("/external-api/success", () =>
{
    return Results.Ok("External API success");
});

app.MapGet("/external-api/fail", () =>
{
    return Results.StatusCode(500);
});

app.MapGet("/external-api/slow", async () =>
{
    await Task.Delay(5000);
    return Results.Ok("Slow response");
});

app.MapGet("/test/fail", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetFailingDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

app.MapGet("/test/success", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetSuccessfulDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

app.MapGet("/test/slow", async (ExternalApiService service) =>
{
    try
    {
        var result = await service.GetSlowDataAsync();
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});

app.Run();

public class ExternalApiService
{
    private readonly HttpClient _httpClient;

    public ExternalApiService(IHttpClientFactory factory)
    {
        _httpClient = factory.CreateClient("external-api");
    }

    public async Task<string> GetFailingDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/fail");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetSuccessfulDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/success");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetSlowDataAsync()
    {
        var response = await _httpClient.GetAsync("/external-api/slow");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Conclusion

Handling failures in HTTP calls is not optional in modern applications. Network issues, timeouts, and temporary service outages are part of everyday development, especially when working with distributed systems.

Retry can help, but as we have seen, it is not enough on its own. If used incorrectly, it can increase load, create instability, and even introduce subtle bugs like duplicated operations.

By combining retry with circuit breaker, and using a structured approach like the one provided by Polly v8, you can build applications that behave more predictably under failure and recover more gracefully.

The key takeaway is simple:
resilience is not about reacting to errors, but about designing how your system behaves when things go wrong.

If you think your friends or network would find this article useful, please consider sharing it with them. Your support is greatly appreciated.

Thanks for reading!

Discover CodeSwissKnife Bar, your all-in-one, offline Developer Tools from Your Menu Bar

Leave A Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.