Overriding Default HTTP Resilience Handlers in .NET
Learn how to override global HTTP resilience handlers in .NET so one HttpClient can use its own retry, timeout, and circuit breaker rules.
Imagine your school has one bell schedule for every classroom. The bell rings, everyone moves, and it works fine for most classes. But the science lab needs longer periods because experiments take time. You do not want to change the bell for the whole school. You just want one room to follow its own clock.
HTTP resilience in .NET works the same way. You set up one safe set of rules for all your HttpClients. But sometimes one client talks to a slow or fragile service and needs different rules. This post shows you how to give that one client its own schedule, without touching the rest of the school.
What "resilience" means here
When your app calls another service over HTTP, things can go wrong. The network blips. The other server is busy. A request takes too long. Resilience is the set of safety habits that help your app survive these bumps.
The Microsoft.Extensions.Http.Resilience package gives you these habits out of the box. It is built on top of Polly, a well-loved open-source library. You add one line and your HttpClient gets five safety strategies stacked together.
Here is the everyday version of those five strategies.
| Strategy | What it does | Like in real life |
|---|---|---|
| Rate limiter | Caps how many requests run at once | A gate that lets only so many people in |
| Total timeout | Limits the whole call, including retries | "Be home by 9pm, no matter what" |
| Retry | Tries again after a small wait | Knocking on the door a second time |
| Circuit breaker | Stops calling a service that keeps failing | Not phoning a friend whose line is dead |
| Attempt timeout | Limits each single try | "Wait only 10 minutes per knock" |
These run from outermost to innermost. The request passes through them like layers of an onion.
Adding the standard handler
You usually start a client like this. First install the package.
dotnet add package Microsoft.Extensions.Http.ResilienceThen register a client and add the standard handler.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddHttpClient<ExampleClient>(client =>
{
client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
})
.AddStandardResilienceHandler();
var app = builder.Build();
app.Run();That one call, AddStandardResilienceHandler(), gives you all five strategies with sensible defaults. The table below shows the defaults you get for free.
| Setting | Default value |
|---|---|
| Max retries | 3 |
| Retry backoff | Exponential with jitter |
| First retry delay | About 2 seconds |
| Total timeout | 30 seconds |
| Attempt timeout | 10 seconds |
| Circuit breaker failure ratio | 10% |
| Circuit break duration | 5 seconds |
For most apps, you can stop here. The defaults are good. The trouble starts when you set a default for every client and then want one client to be different.
The global default trap
Many real apps, and almost every app built with .NET Aspire, set one default for all clients. This is tidy. You write the rule once.
builder.Services
.AddHttpClient()
.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());Now every HttpClient in your app gets the standard handler. Nice and consistent.
But here is the catch. Suppose one client, say a GitHub client, talks to an API with a strict rate limit. Retrying too fast makes things worse. You want fewer retries and a longer wait. So you try this.
builder.Services
.AddHttpClient("github")
.AddResilienceHandler("custom", pipeline =>
{
pipeline.AddTimeout(TimeSpan.FromSeconds(10));
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
});You run it, and... your custom rules do not take over. The github client still has the global standard handler plus your new one stacked on top. Two handlers now fight each other. That is not what you wanted.
This is the trap. Adding a handler does not replace the old one. It just adds another layer.
Why stacking fails
Steps
Global default
Standard handler on all clients
Your custom add
Second handler added on top
Result
Both run, behavior is wrong
The fix: remove first, then add
The rule is simple. Before you add your own handler to a specific client, remove the inherited one. Then add yours on a clean slate.
The package gives you a method for this: RemoveAllResilienceHandlers(). It clears every resilience handler that was registered on that client, including the one from the global default.
builder.Services
.AddHttpClient("github")
.RemoveAllResilienceHandlers()
.AddResilienceHandler("custom", pipeline =>
{
pipeline.AddTimeout(TimeSpan.FromSeconds(10));
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
});Now the github client uses only your pipeline. Every other client keeps the global default. One room, its own clock.
Swapping in a different standard handler
Sometimes you do not want a hand-built pipeline. You just want a different ready-made one. A common case is hedging. Hedging sends a second request in parallel when the first one is slow, and uses whichever answer comes back first. It is great for read-heavy, latency-sensitive calls.
The official docs show this exact pattern.
// Default for all clients: the standard resilience handler.
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());
// For the "custom" client, swap to the hedging handler.
builder.Services
.AddHttpClient("custom")
.RemoveAllResilienceHandlers()
.AddStandardHedgingHandler();Remove the inherited standard handler, then add the hedging handler. Clean swap, no stacking.
Keep one rule in mind from the docs: add only one resilience handler per client. Stacking handlers makes behavior hard to predict. If you need many strategies, put them all inside a single AddResilienceHandler call.
What if RemoveAllResilienceHandlers is not there?
RemoveAllResilienceHandlers is built in now, but older versions of the package did not have it. If you are on an older version, you can write a tiny extension method that does the same job. It walks the list of message handlers and removes any that are a ResilienceHandler.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
public static class ResilienceHttpClientBuilderExtensions
{
public static IHttpClientBuilder RemoveAllResilienceHandlers(
this IHttpClientBuilder builder)
{
builder.ConfigureAdditionalHttpMessageHandlers(static (handlers, _) =>
{
for (int i = handlers.Count - 1; i >= 0; i--)
{
if (handlers[i] is ResilienceHandler)
{
handlers.RemoveAt(i);
}
}
});
return builder;
}
}It loops from the end to the start, so removing items does not skip any. Once the built-in method is available to you, prefer that and delete this helper. Knowing how it works under the hood still helps you trust what is happening.
Decision path for one client
Steps
Need different rules?
If no, keep the global default
Remove handlers
Call RemoveAllResilienceHandlers
Pick a replacement
Custom pipeline or hedging
Building a fully custom pipeline
When the standard defaults do not fit, you can build your own pipeline strategy by strategy with AddResilienceHandler. This gives you full control over retry, circuit breaker, and timeout settings.
builder.Services
.AddHttpClient("payments")
.RemoveAllResilienceHandlers()
.AddResilienceHandler("payments-pipeline", pipeline =>
{
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(10),
FailureRatio = 0.2,
MinimumThroughput = 3
});
pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});Notice the order. Retry sits outside, then the circuit breaker, then the timeout. The request travels outside-in, and the response travels back inside-out. The same onion idea as the standard handler, but every layer is yours to tune.
Watching it work with logging
It helps to actually see the retries happen, so you trust your rules. The resilience package writes log messages when a strategy fires. If you turn on logging for the resilience category, you will see lines telling you a retry was attempted, how long the wait was, and why the call failed.
Add simple console logging in your startup and run a request against a service that returns errors. You will notice the retries arrive with growing gaps between them. That growing gap is the exponential backoff doing its job. The small random wobble on each gap is the jitter. Jitter matters because, without it, many clients would retry at the exact same moment and hammer the server together. A little randomness spreads them out.
If you do not see any retries, check two things. First, is the failing status code one the handler treats as transient? The handler retries on HTTP 408, HTTP 429, and HTTP 500 and above. A plain HTTP 404 is not retried, because a missing page will still be missing on the next try. Second, did you accidentally stack two handlers, so the wrong one is winning? When behavior looks strange, this is almost always the cause.
Choosing values that make sense together
The numbers in your pipeline are not independent. They have to agree with each other. The most common mistake is a total timeout that is too small for the retries you asked for.
Picture this. You set three retries, each waiting about two seconds, and each attempt allowed ten seconds. But your total timeout is only five seconds. The total timeout will cut the whole thing off before even the first retry can finish. Your retries never get a chance to run.
Here is a small table to keep the relationship clear.
| Value | Rule of thumb | Why |
|---|---|---|
| Attempt timeout | Shorter than total timeout | Each try must fit inside the whole budget |
| Total timeout | Big enough for all retries plus waits | Otherwise retries get cut off |
| Max retries | Small for slow services | Too many retries pile up load |
| Backoff | Exponential with jitter | Spreads retries out, avoids stampedes |
When you change one number, glance at the others. Treat them as a team, not as separate knobs. A good habit is to add up the worst case: every attempt taking its full time, plus every backoff wait, and make sure that sum is below your total timeout.
A safety note about retries on POST
Retries are great for safe calls like a GET. But blindly retrying a POST can be dangerous. If a POST creates a new order, a retry might create a second order. That is real money and real bugs.
The standard handler lets you turn retries off for risky methods.
builder.Services
.AddHttpClient<OrdersClient>()
.AddStandardResilienceHandler(options =>
{
// Do not retry POST or DELETE on this client.
options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Delete);
});There is also DisableForUnsafeHttpMethods(), which switches off retries for POST, PATCH, PUT, DELETE, and CONNECT in one call. Use it when in doubt. It is safer to retry too little than to duplicate data.
How it all fits together
Here is the big picture. You keep one global default for the whole app. For the rare client that needs something special, you remove the inherited handler and add your own. Every other client stays untouched.
This keeps your code clean. The default lives in one place. The exceptions are clearly marked, so the next person reading your code can see exactly which client is special and why.
A quick checklist before you ship
- Set the global default once, near the top of your startup code.
- For each special client, call
RemoveAllResilienceHandlers()before adding the new one. - Never stack two resilience handlers on the same client.
- Turn off retries for POST and other unsafe methods unless you are sure they are safe.
- Keep custom timeouts shorter than the total timeout so retries actually have room to run.
References and further reading
- Microsoft Learn: Build resilient HTTP apps, key development patterns — the official guide covering
AddStandardResilienceHandler,RemoveAllResilienceHandlers, hedging, and custom pipelines. - Milan Jovanovic: Overriding Default HTTP Resilience Handlers in .NET — a clear community walkthrough of the override problem and the extension-method fix.
- NuGet: Microsoft.Extensions.Http.Resilience — the package itself, with the latest version numbers.
- Polly documentation: Strategies — deep details on retry, circuit breaker, timeout, and hedging.
Quick recap
- Resilience adds safety habits to HTTP calls: rate limiting, timeouts, retries, and a circuit breaker.
AddStandardResilienceHandler()gives you all five strategies with good defaults in one line.ConfigureHttpClientDefaultsapplies one rule to every client, which is tidy but rigid.- Adding a second handler does not replace the first one. It stacks, and behavior breaks.
- Call
RemoveAllResilienceHandlers()first, then add your own custom or hedging handler. - If your package version lacks that method, a small
ConfigureAdditionalHttpMessageHandlershelper does the same. - Disable retries for POST and other unsafe methods to avoid duplicate side effects.
Related Posts
Building Resilient Cloud Applications With .NET
Learn to build resilient cloud apps in .NET with retries, timeouts, and circuit breakers using Polly and Microsoft.Extensions.Resilience.
Retries and Resilience in .NET with Polly and Microsoft Resilience
Learn retries, timeouts, and circuit breakers in .NET using Polly v8 and Microsoft.Extensions.Http.Resilience, with simple examples a beginner can follow.
The Right Way to Use HttpClient in .NET (Beginner Guide)
Learn the right way to use HttpClient in .NET. Avoid socket exhaustion and stale DNS with IHttpClientFactory, typed clients, and resilience.
When Your Use Case Half-Succeeds: Designing for Partial Failure in .NET
Learn how to design .NET use cases that survive partial failure using outbox, saga, idempotency and compensation patterns, explained simply.
The False Comfort of the Happy Path: Decoupling Your Services
Learn why the happy path lies to you, and how decoupling .NET services with messaging, retries, and circuit breakers keeps your app calm when things break.
Extending HttpClient With Delegating Handlers in ASP.NET Core
Learn how DelegatingHandlers build a middleware pipeline for HttpClient in ASP.NET Core to add logging, auth, and retries with IHttpClientFactory.