HttpClient Resilience in .NET 10: Timeout, Retry, and Circuit Breaker with Microsoft.Extensions.Http.Resilience
HTTP calls fail. That's not a bug in your code -- it's a property of distributed systems. Networks drop packets, upstream services restart, load balancers time out, and rate limiters push back. A well-written .NET application doesn't pretend those failures won't happen. It builds in resilience from the start. The HttpClient retry policy C# story took a major step forward in .NET 8 -- Microsoft.Extensions.Http.Resilience gives you first-class retry, circuit breaker, hedging, and timeout support without reaching for raw Polly directly. This article demonstrates the full API as it stands in .NET 10, and every API shown is available to you on .NET 8 or .NET 9 as well.
If you're looking for the big-picture HttpClient guide, start with HttpClient in C#: The Complete Guide first and come back here when you're ready to harden your HTTP layer.
Why HTTP Calls Fail and Why That's Expected
Distributed systems are unreliable by nature. When your service calls an external API, you're putting trust in every hop between your process and the remote server: your NIC, your OS network stack, routers, load balancers, TLS terminators, and finally the service itself. Any of those can introduce latency or fail entirely.
The key insight is that most failures are transient -- they go away on their own. A service restarting under a rolling deployment will be unavailable for a few seconds, then come back healthy. A rate limiter returning HTTP 429 wants you to slow down and try again shortly. A momentarily overloaded upstream will recover. These failures are fundamentally different from a 404 Not Found or a 400 Bad Request, which are permanent: retrying them is pointless and can even make things worse.
A solid HttpClient retry policy C# developers rely on in production distinguishes transient from permanent failures:
- Retries transient errors with backoff and jitter
- Times out requests that hang indefinitely
- Breaks the circuit when a downstream is clearly unhealthy, preventing cascade failures
- Hedges duplicate requests when you need low-latency guarantees
The Microsoft.Extensions.Http.Resilience package handles all four, and it does it in a way that integrates naturally with .NET 10's dependency injection, logging, and metrics infrastructure.
HttpClient.Timeout vs CancellationToken -- What's the Difference?
Before diving into resilience pipelines, it's worth untangling two overlapping timeout mechanisms that trip up a lot of developers.
HttpClient.Timeout is a global setting. It applies to every request made by that HttpClient instance and uses TimeSpan. When it expires, you get a TaskCanceledException wrapping an OperationCanceledException. It's a property on the HttpClient object itself:
var client = new HttpClient
{
// Every request gets a maximum of 10 seconds, regardless of what the caller wants
Timeout = TimeSpan.FromSeconds(10)
};
A CancellationToken, on the other hand, is per-request. You pass it into GetAsync, SendAsync, etc. The caller controls cancellation -- whether that's a user pressing Cancel, an ASP.NET Core request lifetime ending, or a CancellationTokenSource you created yourself with its own timeout:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// This specific request has a 5-second budget
var response = await httpClient.GetAsync("/api/data", cts.Token);
When both are in play, whichever fires first wins. Both throw TaskCanceledException, which makes distinguishing them slightly annoying -- you have to check ex.CancellationToken.IsCancellationRequested to tell them apart.
The resilience pipeline you'll set up shortly gives you a much cleaner third option: attempt timeout (per individual try) and total request timeout (across all retries combined). These are configured declaratively, work with the rest of the pipeline, and emit telemetry automatically.
The Old Way -- Polly Directly (and Why the New Approach Is Better)
If you've been writing .NET for a few years, you've almost certainly used Polly directly. Pre-.NET 8, every HttpClient retry policy C# project started with code that looked something like this:
// Old approach -- raw Polly v7 wired manually
var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (exception, timespan, attempt, ctx) =>
logger.LogWarning("Retry {Attempt} after {Delay}ms", attempt, timespan.TotalMilliseconds));
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(exceptionsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30));
var combinedPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy);
This works, but it has real friction:
- Version coupling: Raw Polly v7 and v8 have breaking API changes. Mixing them with packages that depend on Polly indirectly causes DLL conflicts.
- Boilerplate: Wiring policies into
IHttpClientFactoryrequiresAddPolicyHandlerextension methods fromMicrosoft.Extensions.Http.Polly. - No built-in telemetry: You have to instrument everything yourself.
- No typed HTTP error awareness: The raw policy doesn't know about 429 Retry-After headers or 5xx transient semantics unless you teach it explicitly.
Microsoft.Extensions.Http.Resilience is Microsoft's official wrapper around Polly v8. It does all that wiring for you, uses HTTP-aware defaults, integrates with ILogger and IMeterFactory, and requires no direct reference to Polly in your project at all.
Microsoft.Extensions.Http.Resilience -- Available Since .NET 8
The package ships independently of the .NET SDK and has been available since .NET 8, refined through .NET 9 and .NET 10:
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.*" />
It depends on Polly.Core (v8) and Microsoft.Extensions.Http transitively, so your project doesn't need to add a Polly or Polly.Core package reference directly. For basic AddStandardResilienceHandler usage, you never touch the Polly namespace at all. Advanced scenarios -- like per-request context keys using ResiliencePropertyKey<T> -- do use types from the Polly namespace, but since Polly.Core is already a transitive dependency, you only need the using Polly; directive, not a separate package reference. The result is a clean API surface that lives entirely in the Microsoft.Extensions.Http.Resilience namespace and hangs off the IHttpClientBuilder returned by AddHttpClient.
The two main entry points are:
AddStandardResilienceHandler()-- opinionated, production-ready defaults in one callAddResilienceHandler(name, configure)-- full control over a custom pipeline
There's also AddStandardHedgingHandler() for latency-sensitive scenarios, which gets its own section.
AddStandardResilienceHandler() -- What It Configures Out of the Box
One call wires up a four-layer pipeline:
using Microsoft.Extensions.Http.Resilience;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com");
})
.AddStandardResilienceHandler();
var app = builder.Build();
app.Run();
That single .AddStandardResilienceHandler() installs a complete HttpClient retry policy C# applications can use in production, structured as four layers (outermost to innermost):
| Layer | Strategy | Default |
|---|---|---|
| 1 | Total request timeout | 30 seconds |
| 2 | Retry | 3 attempts, exponential backoff (2s base), jitter enabled |
| 3 | Circuit breaker | 30s sampling, 100 min throughput, 10% failure ratio, 5s break |
| 4 | Attempt timeout | 10 seconds per individual attempt |
The retry and circuit breaker are both HTTP-aware. They handle HttpRequestException, TaskCanceledException, and any response with a status code in the transient range (408, 429, 5xx). The retry strategy also respects Retry-After response headers automatically -- if a server says "wait 3 seconds before retrying," the pipeline waits 3 seconds.
For the majority of services, this single line does more than a typical hand-rolled Polly setup -- the HTTP-aware defaults (Retry-After header handling, 5xx/429/408 transient detection) are genuinely hard to replicate correctly from scratch.
Customizing the Standard Pipeline
The defaults are a good starting point, but most real services need tuning. AddStandardResilienceHandler accepts an options callback:
builder.Services
.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com");
})
.AddStandardResilienceHandler(options =>
{
// How long we'll wait across ALL attempts before giving up completely
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(45);
// Retry configuration
options.Retry.MaxRetryAttempts = 5;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
// Jitter spreads out retry storms when many clients hit the same backoff window
options.Retry.UseJitter = true;
// Circuit breaker -- open the circuit after 20% failure rate
options.CircuitBreaker.FailureRatio = 0.2;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(20);
options.CircuitBreaker.MinimumThroughput = 20;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(10);
// Individual attempt timeout -- each try gets at most 8 seconds
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(8);
});
Jitter deserves a special mention. When multiple service instances all hit an error at the same time, they'll all schedule their first retry at the same interval. Without jitter, you get a retry storm -- a synchronized spike of traffic that can overwhelm the upstream all over again. Enabling jitter randomizes the backoff window so retries spread out naturally.
The math: with Delay = 500ms, Exponential backoff, and jitter enabled, attempts land roughly at: ~500ms ± random, ~1000ms ± random, ~2000ms ± random. Each retry window is wider than the last, and the randomization prevents thundering herds.
Adding a Circuit Breaker with AddResilienceHandler()
AddStandardResilienceHandler is great for typical CRUD APIs. When you need a fully custom pipeline -- say, a payment gateway that should never be retried aggressively -- AddResilienceHandler gives you complete control.
Strategies added to the builder execute in declaration order, outermost first. Put the total timeout first, retry second, circuit breaker third, and attempt timeout last:
builder.Services
.AddHttpClient<PaymentApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.payments.example.com");
})
.AddResilienceHandler("payment-pipeline", pipeline =>
{
// Outermost: total budget for this operation including all retries
pipeline.AddTimeout(TimeSpan.FromSeconds(20));
// Retry conservatively -- payment APIs should not be hammered
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
UseJitter = true,
// Only retry on genuinely transient errors -- not 400 Bad Request
ShouldHandle = args => args.Outcome switch
{
{ Exception: HttpRequestException } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.TooManyRequests } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.ServiceUnavailable } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.GatewayTimeout } => PredicateResult.True(),
_ => PredicateResult.False()
}
});
// Circuit breaker: open after 30% failures, stay open for 15 seconds
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
FailureRatio = 0.3,
BreakDuration = TimeSpan.FromSeconds(15),
});
// Innermost: each individual attempt gets 5 seconds max
pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});
The circuit breaker has three states:
- Closed -- normal operation, requests flow through
- Open -- the circuit is tripped, all requests fail immediately without touching the downstream (
BrokenCircuitExceptionis thrown). This protects both your service and the struggling downstream. - Half-open -- after
BreakDurationelapses, one probe request is allowed through. If it succeeds, the circuit closes. If it fails, it opens again for anotherBreakDuration.
The fast-fail behavior while the circuit is open is the most important property. A dead downstream service shouldn't slow down every caller with full timeout waits. The circuit breaker converts multi-second hangs into instant failures, which lets your service degrade gracefully instead of exhausting all its threads waiting.
For more on handling these failures gracefully at the API layer, the Error Handling in ASP.NET Core Web API guide covers how to turn exceptions like BrokenCircuitException into appropriate HTTP Problem Details responses.
Hedging -- Parallel Duplicate Requests for Latency-Sensitive Workloads
Retries are sequential: try once, wait, try again. That works for correctness but introduces latency. For read-heavy, idempotent endpoints where you need low p99 latency (search, autocomplete, product listing), hedging is a better fit.
Hedging sends a duplicate request after a short delay if the first request hasn't responded yet. Whichever response arrives first wins; the other is cancelled. It's speculative -- you're using slightly more capacity in exchange for predictable response times.
AddStandardHedgingHandler sets up a full hedged pipeline:
builder.Services
.AddHttpClient<SearchApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.search.example.com");
})
.AddStandardHedgingHandler(options =>
{
// Send a second request if the first hasn't responded in 200ms
options.Hedging.Delay = TimeSpan.FromMilliseconds(200);
// Allow up to 3 parallel attempts total
options.Hedging.MaxHedgedAttempts = 3;
// Total budget across all hedged attempts
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5);
});
A few rules for hedging:
- Only use it for idempotent requests. GET, HEAD, and read-only POSTs are fine. Hedging a payment submission would create duplicate charges.
- Choose the delay carefully. Too short and you double your load on every request. Too long and you don't help your tail latency. 100--300ms is a reasonable starting range for most APIs.
- Monitor upstream capacity. Hedging can meaningfully increase traffic to downstream services during high load. Make sure the downstream can handle it.
The standard hedging handler also configures per-endpoint circuit breakers and rate limiters internally, so you get protection against downstream overload even while sending speculative requests.
Per-Endpoint Timeout with ResiliencePropertyKey
Sometimes different operations within the same HttpClient need different timeouts. A quick autocomplete call should fail fast, while a bulk export can wait longer. One approach is using named clients (one per SLA tier). Another is passing per-request context via ResiliencePropertyKey.
Define the key once, then set it per request:
using Polly;
// Define a static key -- reuse the same instance everywhere
public static class ResilienceKeys
{
public static readonly ResiliencePropertyKey<TimeSpan> RequestTimeout =
new("http-request-timeout");
}
Configure the pipeline to read it:
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.catalog.example.com");
})
.AddResilienceHandler("adaptive-timeout", pipeline =>
{
pipeline.AddTimeout(new TimeoutStrategyOptions
{
// Dynamically resolve timeout from the request context
TimeoutGenerator = static args =>
{
if (args.Context.Properties.TryGetValue(
ResilienceKeys.RequestTimeout, out var timeout))
{
return new ValueTask<TimeSpan>(timeout);
}
// Fall back to 10 seconds if no override was set
return new ValueTask<TimeSpan>(TimeSpan.FromSeconds(10));
}
});
pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3 });
});
Set the context in your typed client before sending:
public sealed class CatalogApiClient(HttpClient httpClient)
{
public async Task<ProductList?> SearchFastAsync(
string query, CancellationToken cancellationToken = default)
{
// This call has a 2-second budget -- fail fast for autocomplete
return await SendWithTimeoutAsync(
$"/search?q={Uri.EscapeDataString(query)}",
TimeSpan.FromSeconds(2),
cancellationToken);
}
public async Task<ProductList?> ExportAsync(
string category, CancellationToken cancellationToken = default)
{
// Export is allowed 30 seconds
return await SendWithTimeoutAsync(
$"/export?category={Uri.EscapeDataString(category)}",
TimeSpan.FromSeconds(30),
cancellationToken);
}
private async Task<ProductList?> SendWithTimeoutAsync(
string path, TimeSpan timeout, CancellationToken cancellationToken)
{
var context = ResilienceContextPool.Shared.Get(cancellationToken);
context.Properties.Set(ResilienceKeys.RequestTimeout, timeout);
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.SetResilienceContext(context);
try
{
var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<ProductList>(cancellationToken: cancellationToken);
}
finally
{
ResilienceContextPool.Shared.Return(context);
}
}
}
This pattern keeps per-SLA logic in the client code where it belongs, while the pipeline configuration remains centralized and reusable.
Observability -- Resilience Pipeline Metrics and Logs in .NET 10
One of the strongest arguments for Microsoft.Extensions.Http.Resilience over raw Polly is that telemetry comes free. You don't have to instrument your retry callbacks manually.
Logging is automatic. Every resilience event -- retry attempt, circuit breaker state change, timeout -- is logged through ILogger at an appropriate level. Retry attempts log at Warning, circuit breaker opening logs at Error, successful attempts after retry log at Debug. You just need ILogger in your DI container, which every .NET 10 app already has.
Metrics are emitted via the .NET System.Diagnostics.Metrics API, which integrates with OpenTelemetry. To wire them into your observability stack:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("Microsoft.Extensions.Http.Resilience")
.AddPrometheusExporter());
Key metrics emitted include:
resilience.http.request.duration-- per-attempt latency tagged with strategy name and outcomeresilience.http.pipeline.duration-- end-to-end pipeline duration including retries- Circuit breaker state transitions tagged with the named pipeline
Note: Exact metric names may vary across library versions. Verify the canonical names against the Microsoft.Extensions.Http.Resilience metrics documentation before wiring production dashboards.
With these metrics in a Grafana dashboard, you can spot retry storms (sudden spike in retry count), circuit breaker flapping (rapid open/close cycling), and timeout pressure (high attempt timeout rate) before they become outages.
You can also verify the pipeline is working correctly during development by checking the log output. If you're not seeing Warning-level retry log lines when a downstream is misbehaving, something is misconfigured.
Complete Example -- Typed Client with Full Resilience Pipeline
Putting it all together: a typed WeatherApiClient with a fully configured resilience pipeline registered in a .NET 10 web application. This is the HttpClient retry policy C# pattern you'd carry into a real production service. If you want background on how DI and controllers fit together in an ASP.NET Core service, ASP.NET Core Web API in .NET: The Complete Guide covers that end to end.
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("Microsoft.Extensions.Http.Resilience"));
// Register the typed client with a full resilience pipeline
builder.Services
.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["WeatherApi:BaseUrl"]
?? throw new InvalidOperationException("WeatherApi:BaseUrl is required"));
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddResilienceHandler("weather-pipeline", pipeline =>
{
// 1. Outermost: hard budget for the full operation including all retries
pipeline.AddTimeout(TimeSpan.FromSeconds(30));
// 2. Retry with exponential backoff and jitter
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromMilliseconds(500),
UseJitter = true,
});
// 3. Circuit breaker: open after 20% failures over a 20-second window
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(20),
MinimumThroughput = 15,
FailureRatio = 0.2,
BreakDuration = TimeSpan.FromSeconds(10),
});
// 4. Innermost: each individual attempt gets 6 seconds max
pipeline.AddTimeout(TimeSpan.FromSeconds(6));
});
var app = builder.Build();
app.MapControllers();
app.Run();
The typed client itself uses C# 12 primary constructors (available since .NET 8) and stays focused on its job:
public sealed class WeatherApiClient(HttpClient httpClient, ILogger<WeatherApiClient> logger)
{
public async Task<CurrentWeather?> GetCurrentWeatherAsync(
string city,
CancellationToken cancellationToken = default)
{
logger.LogInformation("Fetching weather for {City}", city);
var response = await httpClient.GetAsync(
$"/v2/weather/current?city={Uri.EscapeDataString(city)}",
cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
logger.LogWarning("City not found: {City}", city);
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CurrentWeather>(
cancellationToken: cancellationToken);
}
public async Task<ForecastData?> GetForecastAsync(
string city,
int days,
CancellationToken cancellationToken = default)
{
var response = await httpClient.GetAsync(
$"/v2/weather/forecast?city={Uri.EscapeDataString(city)}&days={days}",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ForecastData>(
cancellationToken: cancellationToken);
}
}
// The minimal record types for the example
public sealed record CurrentWeather(string City, double TemperatureC, string Condition);
public sealed record ForecastData(string City, IReadOnlyList<DayForecast> Days);
public sealed record DayForecast(DateOnly Date, double HighC, double LowC);
Register the controller that uses it:
[ApiController]
[Route("api/[controller]")]
public sealed class WeatherController(WeatherApiClient weatherClient) : ControllerBase
{
[HttpGet("{city}/current")]
public async Task<IActionResult> GetCurrentWeather(
string city, CancellationToken cancellationToken)
{
var weather = await weatherClient.GetCurrentWeatherAsync(city, cancellationToken);
return weather is null
? NotFound(new { error = $"No weather data for '{city}'" })
: Ok(weather);
}
[HttpGet("{city}/forecast")]
public async Task<IActionResult> GetForecast(
string city, [FromQuery] int days = 5, CancellationToken cancellationToken = default)
{
var forecast = await weatherClient.GetForecastAsync(city, days, cancellationToken);
return forecast is null ? NotFound() : Ok(forecast);
}
}
The pipeline sits entirely below the typed client layer. If the weather API flaps, retries happen automatically. If it's fully down, the circuit breaker opens and callers get immediate failures instead of pile-ups. The controller and client code never need to care about the retry mechanics.
Summary
HTTP failures are not exceptional -- they're the baseline reality of distributed systems. Microsoft.Extensions.Http.Resilience makes handling them practical in .NET 10. You get four complementary strategies -- retry with jitter, circuit breaker, hedging, and layered timeouts -- all wired into IHttpClientFactory, DI, logging, and metrics with minimal boilerplate.
The key decisions to make for your own HttpClient retry policy C# implementation:
- Use
AddStandardResilienceHandlerwhen you want sensible defaults immediately and can customize them via options. - Use
AddResilienceHandlerwhen you need explicit control over pipeline composition, customShouldHandlepredicates, or different strategies for different client types. - Reach for
AddStandardHedgingHandleronly for idempotent, latency-sensitive endpoints where you can absorb the extra upstream load. - Tune timeouts conservatively: start with the defaults, measure with real traffic, then tighten.
Building resilient HTTP communication isn't about being pessimistic. It's about being honest -- failures will happen, and the code that handles them gracefully is the code that runs well in production.
Frequently Asked Questions
Does Microsoft.Extensions.Http.Resilience require me to add Polly as a dependency?
No. Microsoft.Extensions.Http.Resilience depends on Polly.Core (v8) transitively, but your project file only needs to reference Microsoft.Extensions.Http.Resilience. You do not add a Polly or Polly.Core package reference directly. The resilience API you interact with lives entirely in the Microsoft.Extensions.Http.Resilience namespace.
What is the httpclient retry policy c# default behavior when using AddStandardResilienceHandler?
The standard handler retries up to 3 times on transient HTTP errors (status codes 408, 429, and 5xx) and HttpRequestException. It uses exponential backoff starting at 2 seconds with jitter, respects Retry-After headers, and wraps the retry layer in a circuit breaker and timeout. The total operation budget is 30 seconds.
How does the circuit breaker interact with retries?
The circuit breaker sits inside the retry layer in the standard pipeline. Each retry attempt passes through the circuit breaker. If the failure rate exceeds the configured threshold during the sampling window, the circuit opens and the circuit breaker starts rejecting attempts immediately -- before a network call is even made. This means retries stop burning time on a downstream that's clearly unhealthy. Once the break duration elapses, one probe request goes through. Success closes the circuit; failure extends the break.
When should I use hedging instead of retry?
Use hedging when the operation is idempotent (safe to duplicate), the downstream is generally healthy but occasionally slow, and you need low p99 latency. Retries are sequential -- they add latency on failure. Hedging adds a small amount of extra load in exchange for capping your tail latency. A search or autocomplete endpoint is a good fit. A write operation or anything with side effects is not.
Can I use AddResilienceHandler alongside AddStandardResilienceHandler?
No. Microsoft.Extensions.Http.Resilience validates the pipeline at startup and throws InvalidOperationException if more than one resilience handler is registered on the same IHttpClientBuilder. Choose one approach per client: use AddStandardResilienceHandler for opinionated defaults, or AddResilienceHandler for full control, and configure it completely there.
How do I test that my resilience pipeline works?
The cleanest approach is integration testing with a test HTTP server that returns controlled error responses. WireMock.Net or a simple WebApplicationFactory test double that returns 503 on the first N requests and 200 after that lets you verify that retries fire correctly, the circuit breaker opens when expected, and your client handles BrokenCircuitException without crashing. You can also observe the structured log output to confirm retry attempts are logged. For a deep dive on the WebApplicationFactory testing approach, Testing ASP.NET Core Web API: WebApplicationFactory and Integration Tests covers the full setup including mocking downstream HTTP dependencies.
What happens if the CancellationToken is cancelled during a retry wait?
The pipeline respects CancellationToken cancellation immediately. If the caller cancels during an exponential backoff wait between retry attempts, the operation throws OperationCanceledException rather than proceeding with the next attempt. The token is threaded through the entire pipeline from the outer CancellationToken passed to SendAsync.

