Skip to main content
SEMastery
DevOpsintermediate

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.

12 min readUpdated February 2, 2026

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.

StrategyWhat it doesLike in real life
Rate limiterCaps how many requests run at onceA gate that lets only so many people in
Total timeoutLimits the whole call, including retries"Be home by 9pm, no matter what"
RetryTries again after a small waitKnocking on the door a second time
Circuit breakerStops calling a service that keeps failingNot phoning a friend whose line is dead
Attempt timeoutLimits 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.

The five layers of the standard resilience handler, outer to inner

Adding the standard handler

You usually start a client like this. First install the package.

dotnet add package Microsoft.Extensions.Http.Resilience

Then 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.

SettingDefault value
Max retries3
Retry backoffExponential with jitter
First retry delayAbout 2 seconds
Total timeout30 seconds
Attempt timeout10 seconds
Circuit breaker failure ratio10%
Circuit break duration5 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

Global default
Your custom add
Result

Steps

1

Global default

Standard handler on all clients

2

Your custom add

Second handler added on top

3

Result

Both run, behavior is wrong

Adding a second handler does not remove the first one

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.

Remove the inherited handler, then add your own

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

Need different rules?
Remove handlers
Pick a replacement

Steps

1

Need different rules?

If no, keep the global default

2

Remove handlers

Call RemoveAllResilienceHandlers

3

Pick a replacement

Custom pipeline or hedging

How to decide what to do for a specific HttpClient

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.

A custom pipeline for a payments client

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.

ValueRule of thumbWhy
Attempt timeoutShorter than total timeoutEach try must fit inside the whole budget
Total timeoutBig enough for all retries plus waitsOtherwise retries get cut off
Max retriesSmall for slow servicesToo many retries pile up load
BackoffExponential with jitterSpreads 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.

Global default with one overridden client

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

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.
  • ConfigureHttpClientDefaults applies 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 ConfigureAdditionalHttpMessageHandlers helper does the same.
  • Disable retries for POST and other unsafe methods to avoid duplicate side effects.

Related Posts