To add a circuit breaker to outbound HTTP calls in .NET, install Microsoft.Extensions.Http.Resilience and call AddStandardResilienceHandler() on the IHttpClientBuilder returned by AddHttpClient. That adds retries, a circuit breaker and timeouts built on Polly with default values. For control over the thresholds, use AddResilienceHandler and configure AddRetry and AddCircuitBreaker yourself.
Who this is for and what you will have
This guide is for .NET developers whose service calls other HTTP APIs and who want it to survive slow or failing dependencies without piling up requests. By the end you will have a typed HttpClient with a resilience pipeline, understand the documented defaults, be able to write a custom pipeline with tuned retry and circuit breaker settings, and know how to handle the exception thrown when the circuit opens.
What a circuit breaker does
A retry helps when a failure is brief. If the dependency is down for minutes, retries add load to a service that is already struggling and make every caller wait. A circuit breaker watches recent calls and, once failures cross a threshold, blocks further calls for a short time so callers fail immediately.
Polly's circuit breaker has four states:
| State | Behaviour |
|---|---|
| Closed | Normal operation; calls execute |
| Open | Calls are blocked |
| Half-open | After the break duration, calls are allowed again to test recovery |
| Isolated | Held open manually; calls are blocked |
If the test calls succeed in the half-open state the circuit closes, and if they fail it opens again.
Prerequisites
- A .NET project that uses dependency injection, such as ASP.NET Core or the .NET Generic Host.
- The NuGet package
Microsoft.Extensions.Http.Resilience. The package relies onMicrosoft.Extensions.Resilienceand Polly. - Calls made through
IHttpClientFactory(AddHttpClient), because the resilience extensions attach to theIHttpClientBuilder. For a static or singletonHttpClientwithout a container, Microsoft points to its HttpClient guidelines on resilience with static clients.
Install the package:
dotnet add package Microsoft.Extensions.Http.ResilienceStep 1: Add the standard resilience handler
Register a typed client and chain the standard handler.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
IHttpClientBuilder httpClientBuilder = builder.Services.AddHttpClient<ExampleClient>(
configureClient: static client =>
{
client.BaseAddress = new("https://api.contoso.com");
});
httpClientBuilder.AddStandardResilienceHandler();The documentation advises adding only one resilience handler to a client and not stacking handlers. If you need several, use AddResilienceHandler to customize the strategies.
The typed client itself is ordinary code:
using System.Net.Http.Json;
internal sealed class ExampleClient(HttpClient client)
{
public Task<Order[]?> GetOrdersAsync(CancellationToken ct) =>
client.GetFromJsonAsync<Order[]>("/orders", ct);
}
public record class Order(int Id, string Status);Defaults of the standard handler
The standard handler chains five strategies, listed from outermost to innermost:
| Order | Strategy | What it does | Defaults |
|---|---|---|---|
| 1 | Rate limiter | Limits concurrent requests to the dependency | Queue 0, permit limit 1,000 |
| 2 | Total timeout | Caps the whole execution including retries | 30 seconds |
| 3 | Retry | Retries slow or transiently failing calls | 3 retries, exponential backoff, jitter on, 2-second delay |
| 4 | Circuit breaker | Blocks calls when too many failures or timeouts occur | Failure ratio 10%, minimum throughput 100, sampling duration 30 seconds, break duration 5 seconds |
| 5 | Attempt timeout | Limits each single attempt | 10 seconds |
The retry and circuit breaker strategies both handle HTTP 500 and above, HTTP 408 (request timeout) and HTTP 429 (too many requests), plus HttpRequestException and TimeoutRejectedException.
Because the order is outermost to innermost, the total timeout wraps the retries, and each retry attempt passes through the circuit breaker and gets its own attempt timeout.
Step 2: Do not retry unsafe methods blindly
By default the standard handler retries every HTTP method. Microsoft warns this can be harmful: if a POST inserts a record, a retry can duplicate it. Two options are documented.
Disable specific methods:
httpClientBuilder.AddStandardResilienceHandler(options =>
{
options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Delete);
});Or disable all methods the HTTP specification considers unsafe, which are POST, PATCH, PUT, DELETE and CONNECT:
httpClientBuilder.AddStandardResilienceHandler(options =>
{
options.Retry.DisableForUnsafeHttpMethods();
});If the API supports idempotency keys, retrying writes becomes safe. See Idempotent API design: keys, storage and retention and Webhook retry and idempotency patterns.
Step 3: Write a custom pipeline
When the defaults do not fit, use AddResilienceHandler with a name and configure the strategies. This example is based on Microsoft's own sample.
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;
httpClientBuilder.AddResilienceHandler(
"CustomPipeline",
static builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
BackoffType = DelayBackoffType.Exponential,
MaxRetryAttempts = 5,
UseJitter = true
});
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(10),
FailureRatio = 0.2,
MinimumThroughput = 3,
ShouldHandle = static args =>
{
return ValueTask.FromResult(args is
{
Outcome.Result.StatusCode:
HttpStatusCode.RequestTimeout or
HttpStatusCode.TooManyRequests
});
}
});
builder.AddTimeout(TimeSpan.FromSeconds(5));
});In this pipeline the circuit opens when at least 3 calls occur within 10 seconds and 20 percent of them fail, with failure defined as a 408 or 429 response. Note that the custom ShouldHandle here replaces the default, so a 500 response or an HttpRequestException would not count as a failure for this breaker. Choose the predicate deliberately.
A warning from the documentation: if you use both retry and timeout strategies and customize ShouldHandle on the retry, decide whether it should handle Polly's timeout exception. Polly throws TimeoutRejectedException, which inherits from Exception, not the standard TimeoutException.
Strategy order matters in a custom pipeline. Strategies added first are outermost. The standard handler puts the retry outside the circuit breaker, so each retry attempt is counted by the breaker and a retry that hits an open circuit fails fast.
Circuit breaker settings explained
These are the Polly circuit breaker options and their defaults when you construct the strategy directly:
| Option | Default | Meaning |
|---|---|---|
ShouldHandle | Any exception except OperationCanceledException | What counts as a failure |
FailureRatio | 0.1 | Ratio of failures to total calls that opens the circuit |
MinimumThroughput | 100 | Minimum calls in the sampling window before the ratio is evaluated |
SamplingDuration | 30 seconds | Window over which the ratio is measured |
BreakDuration | 5 seconds | How long the circuit stays open |
BreakDurationGenerator | null | Computes the break duration at runtime and overrides BreakDuration if set |
ManualControl | null | Lets code isolate or close the circuit |
StateProvider | null | Exposes the current state |
OnClosed, OnOpened, OnHalfOpened | null | Callbacks on state changes |
One setting causes the most confusion: if MinimumThroughput is not reached within SamplingDuration, the circuit will not break regardless of the failure ratio. With the defaults, a service making 20 calls a minute can see every call fail and never open the circuit. For low-traffic clients, lower MinimumThroughput, for example to 5 or 10, and test the result.
Note that the options for the standard handler are exposed on HttpStandardResilienceOptions, so you can adjust the circuit breaker without rewriting the whole pipeline:
httpClientBuilder.AddStandardResilienceHandler(options =>
{
options.CircuitBreaker.MinimumThroughput = 10;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
});Step 4: Handle the broken circuit
When the circuit is open, the call does not run and Polly throws BrokenCircuitException, which exposes RetryAfter and TelemetrySource. A circuit held open manually throws IsolatedCircuitException. Catch these where you can give the user a sensible response.
using Polly.CircuitBreaker;
try
{
var orders = await client.GetOrdersAsync(ct);
return Results.Ok(orders);
}
catch (BrokenCircuitException)
{
// The dependency is failing; fail fast with a clear message or a cached value.
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "The orders service is temporarily unavailable.");
}Returning a cached value, a default or a queued task is usually better than an error when the data allows it. The circuit breaker only gives you speed and protection; the fallback is your decision.
Monitoring state
Expose the current state with a CircuitBreakerStateProvider and use OnOpened and OnClosed callbacks to log transitions, so on-call engineers can see when a dependency was cut off.
using Polly.CircuitBreaker;
var stateProvider = new CircuitBreakerStateProvider();
var options = new CircuitBreakerStrategyOptions
{
StateProvider = stateProvider,
OnOpened = args =>
{
Console.WriteLine("Circuit opened.");
return default;
}
};
// Later: stateProvider.CircuitState returns Closed, Open, HalfOpen or Isolated.For manual control, CircuitBreakerManualControl provides IsolateAsync() and CloseAsync(). Isolating the circuit blocks all calls, which can be useful during a planned dependency outage.
Hedging as an alternative
The package also offers AddStandardHedgingHandler(), which retries slow requests in parallel and uses a pool of circuit breakers so unhealthy endpoints are not hedged against. Its defaults include a 30-second total timeout, a hedging delay of 2 seconds and up to 10 attempts, with a per-endpoint rate limiter, circuit breaker and 10-second attempt timeout. It suits calls to multiple equivalent endpoints. If you only call one endpoint, the standard resilience handler is the simpler choice. You can switch between them with RemoveAllResilienceHandlers().
Verifying your configuration
- Point the client at a test server you control that returns HTTP 500.
- Send more requests than
MinimumThroughputinside the sampling duration. - Confirm that after the ratio is crossed, calls start throwing
BrokenCircuitExceptionimmediately rather than waiting for timeouts. - Wait for the break duration, send one request to a healthy server and confirm the circuit closes.
- Check logs or metrics for the open and close transitions.
Test with a deliberately small MinimumThroughput and SamplingDuration in a test environment so the circuit opens quickly, and keep production values in configuration.
Troubleshooting
The circuit never opens even though every call fails
The sampling window did not see enough calls. With the defaults you need 100 requests in 30 seconds. Lower MinimumThroughput, and make sure ShouldHandle treats the failure you see as a failure. A custom predicate that only handles 408 and 429 will ignore 500 responses.
Duplicate records after a transient error
The retry strategy resent a POST. Use DisableFor or DisableForUnsafeHttpMethods, or add idempotency keys to the API call.
Requests time out at 10 seconds on a slow endpoint
The default attempt timeout is 10 seconds, and the default total timeout is 30 seconds. For slow calls such as report generation, raise the attempt and total timeouts through the options passed to AddStandardResilienceHandler, keeping the total larger than the attempt timeout multiplied by the number of attempts you need.
Timeouts are not retried in a custom pipeline
Polly throws TimeoutRejectedException, not TimeoutException. If you wrote a ShouldHandle for the retry strategy, include that exception.
gRPC client throws InvalidOperationException about ConfigureHttpClient
Documented known issue: Grpc.Net.ClientFactory version 2.63.0 or earlier does not work with the standard resilience or hedging handlers. Upgrade to 2.64.0 or later.
Application Insights telemetry disappears
Documented known issue for Application Insights 2.22.0 or lower: registering resilience before Application Insights can stop telemetry. Update to 2.23.0 or later, or register Application Insights first.
Retry on HTTP 429 hammers a rate-limited API
The standard handler treats 429 as a transient error. For APIs that return a Retry-After header, see LLM API rate limits: retry and backoff in Python for the general pattern, and check the Polly retry documentation for how to honour the header in your pipeline.
Checklist
- Install
Microsoft.Extensions.Http.Resilienceand useAddHttpClientso you can add the handler. - Add exactly one resilience handler per client.
- Start with
AddStandardResilienceHandler()and read its defaults before changing them. - Disable retries for unsafe methods unless the endpoint is idempotent.
- Lower
MinimumThroughputfor low-traffic clients. - Catch
BrokenCircuitExceptionand provide a fallback or clear error. - Log circuit state changes and test the open and recovery behaviour before release.
References
- Build resilient HTTP apps (Microsoft Learn): https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience
- Polly circuit breaker strategy: https://www.pollydocs.org/strategies/circuit-breaker.html
- Polly retry strategy: https://www.pollydocs.org/strategies/retry.html
- Microsoft.Extensions.Http.Resilience on NuGet: https://www.nuget.org/packages/Microsoft.Extensions.Http.Resilience
Comments
Questions and tips are welcome. Comments are checked by the EpiFive team before they appear; please do not share passwords, phone numbers or other private details.