HTTP Resilience in .NET (Part 2): Hedging & Custom Pipelines

HTTP Resilience in .NET (Part 2): Hedging & Custom Pipelines
Newsletter edition β Resilience series, 2 of 3 Part 1 covered retries, timeouts, and circuit breakers. But retries are sequential β you fail, you wait, you try again. What if the problem isn't failure, but slowness? That's where hedging comes in.
Missed Part 1? Start with Retries, Timeouts & Circuit Breakers. This issue assumes you've already added Microsoft.Extensions.Http.Resilience.
The Problem: Tail Latency
Imagine a service whose median response is 20ms, but whose 99th percentile is 2 seconds because of an occasional slow node. Retries don't help β the request didn't fail, it's just slow. By the time your attempt timeout fires, you've already burned that time.
Hedging flips the strategy: instead of waiting for one slow request, send a second one in parallel and take whichever responds first.
| Strategy | When the dependency is⦠| Behavior |
|---|---|---|
| Retry | Failing (errors) | Wait, then try again sequentially |
| Hedging | Slow (high tail latency) | Fire another request in parallel, take the winner |
| β Summary | β | Retry fixes failures; hedging fixes slowness |
Pattern 4: Hedging
Hedging "retries slow requests in parallel." If the first request doesn't answer within a hedging delay, a second attempt launches β without canceling the first. Whoever wins, wins.
The trade-off is simple: lower latency in exchange for more load. You're doing redundant work to shave off the slow tail, so hedging shines for idempotent reads and is dangerous for non-idempotent writes.
β οΈ Hedging multiplies traffic. A misconfigured hedge against a struggling backend is a self-inflicted DDoS. That's why the standard handler pairs hedging with per-endpoint circuit breakers.
The Standard Hedging Handler
Swap the standard resilience handler for the hedging one:
builder.Services .AddHttpClient<ExampleClient>(client => client.BaseAddress = new("https://api.example.com")) .AddStandardHedgingHandler();
It chains five strategies. The key difference from Part 1: the inner three run per endpoint, so one unhealthy host can't poison the others:
| Order | Strategy | Default | Scope |
|---|---|---|---|
| 1 | Total request timeout | 30s | Whole operation |
| 2 | Hedging | Min 1 / Max 10 attempts, 2s delay | Across endpoints |
| 3 | Rate limiter | Queue: 0, Permit: 1_000 |
Per endpoint |
| 4 | Circuit breaker | 10% ratio, 100 throughput, 30s/5s | Per endpoint |
| 5 | Attempt timeout | 10s | Per endpoint |
| β Summary | β | β | Parallel attempts, but each host is protected independently |
By default, endpoints are selected by URL authority (scheme + host + port), and the pool of circuit breakers ensures an unhealthy endpoint isn't hedged against.
π§ The max hedging attempts correlate to the number of configured route groups. Two groups β at most two parallel attempts.
Routing: Where Do Hedged Requests Go?
Out of the box, hedging just re-sends to the same URL. But you can route attempts across endpoints β great for A/B testing or canary traffic.
Ordered groups (weighted split across endpoints):
builder.Services .AddHttpClient<ExampleClient>() .AddStandardHedgingHandler(static (IRoutingStrategyBuilder routing) => { routing.ConfigureOrderedGroups(static options => { options.Groups.Add(new UriEndpointGroup { Endpoints = { // 3% experimental, 97% stable new() { Uri = new("https://example.net/api/experimental"), Weight = 3 }, new() { Uri = new("https://example.net/api/stable"), Weight = 97 } } }); }); });
Weighted groups (classic A/B/C split, re-selected every attempt):
builder.Services .AddHttpClient<ExampleClient>() .AddStandardHedgingHandler(static (IRoutingStrategyBuilder routing) => { routing.ConfigureWeightedGroups(static options => { options.SelectionMode = WeightedGroupSelectionMode.EveryAttempt; options.Groups.Add(new WeightedUriEndpointGroup { Endpoints = { new() { Uri = new("https://example.net/api/a"), Weight = 33 }, new() { Uri = new("https://example.net/api/b"), Weight = 33 }, new() { Uri = new("https://example.net/api/c"), Weight = 33 } } }); }); });
Building Your Own Pipeline
When the standard handlers don't fit, compose your own with AddResilienceHandler. You pick the strategies and their order:
builder.Services .AddHttpClient<ExampleClient>() .AddResilienceHandler("CustomPipeline", static pipeline => { // Retry β https://www.pollydocs.org/strategies/retry.html pipeline.AddRetry(new HttpRetryStrategyOptions { BackoffType = DelayBackoffType.Exponential, MaxRetryAttempts = 5, UseJitter = true }); // Circuit breaker β https://www.pollydocs.org/strategies/circuit-breaker.html pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { SamplingDuration = TimeSpan.FromSeconds(10), FailureRatio = 0.2, MinimumThroughput = 3, ShouldHandle = static args => ValueTask.FromResult(args is { Outcome.Result.StatusCode: HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests }) }); // Timeout β https://www.pollydocs.org/strategies/timeout.html pipeline.AddTimeout(TimeSpan.FromSeconds(5)); });
β οΈ Order matters. Strategies wrap each other in the order you add them. And remember: if your
ShouldHandleruns alongside a timeout, decide whether it should also handle Polly'sTimeoutRejectedException(which is not the standardTimeoutException).
Bonus: reload config without a restart
Bind options to configuration and call EnableReloads so changing appsettings.json retunes the pipeline live:
builder.Services .AddHttpClient<ExampleClient>() .AddResilienceHandler("AdvancedPipeline", static (ResiliencePipelineBuilder<HttpResponseMessage> pipeline, ResilienceHandlerContext context) => { context.EnableReloads<HttpRetryStrategyOptions>("RetryOptions"); var retryOptions = context.GetOptions<HttpRetryStrategyOptions>("RetryOptions"); pipeline.AddRetry(retryOptions); });
Swapping Handlers Per Client
A common real-world setup: a sane default for everyone, a special case for one client. RemoveAllResilienceHandlers clears the slate:
// Default: every client gets the standard resilience handler services.ConfigureHttpClientDefaults(builder => builder.AddStandardResilienceHandler()); // "custom": drop the default, use hedging instead services.AddHttpClient("custom") .RemoveAllResilienceHandlers() .AddStandardHedgingHandler();
Key Takeaways
- Retry fixes failures; hedging fixes slowness. Use hedging to cut tail latency on idempotent reads.
AddStandardHedgingHandler()runs its inner strategies per endpoint, so one bad host doesn't sink the rest.- Max parallel attempts = number of route groups. Configure ordered or weighted groups for A/B and canary routing.
- Need full control?
AddResilienceHandlerlets you stack retry, circuit breaker, and timeout in any order β with live reload support. - Hedging multiplies load β always pair it with circuit breakers to avoid self-inflicted overload.
Next up β Part 3: Connection Pooling & HttpClient Lifetime. Resilience is useless if you're leaking sockets or stuck on stale DNS. We'll fix the most misused class in .NET: HttpClient.
Resources
- Build resilient HTTP apps: key development patterns
HttpStandardHedgingResilienceOptionsAddStandardHedgingHandler- Polly docs: Hedging resilience strategy
Got questions? Reach out on LinkedIn.
Catch up: Part 1 β Retries, Timeouts & Circuit Breakers.
#dotnet #httpclient #resilience #polly #hedging #taillatency
