http-resilience-part1-retries-timeouts-circuit-breakers

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 ShouldHandle runs alongside a timeout, decide whether it should also handle Polly's TimeoutRejectedException (which is not the standard TimeoutException).

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

  1. Retry fixes failures; hedging fixes slowness. Use hedging to cut tail latency on idempotent reads.
  2. AddStandardHedgingHandler() runs its inner strategies per endpoint, so one bad host doesn't sink the rest.
  3. Max parallel attempts = number of route groups. Configure ordered or weighted groups for A/B and canary routing.
  4. Need full control? AddResilienceHandler lets you stack retry, circuit breaker, and timeout in any order β€” with live reload support.
  5. 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


Got questions? Reach out on LinkedIn.

Catch up: Part 1 β€” Retries, Timeouts & Circuit Breakers.

#dotnet #httpclient #resilience #polly #hedging #taillatency