Skip to content

Commit 92fc087

Browse files
committed
feat: Add comprehensive documentation for HttpClient resilience patterns including standard, hedging, and custom pipelines
1 parent 1f44090 commit 92fc087

6 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
name: csharp-http-resilience
3+
description: Add retry, circuit breaker, timeout, hedging, and rate-limiting to HttpClient-based .NET code using Microsoft.Extensions.Http.Resilience (built on Polly v8). Covers AddStandardResilienceHandler, AddResilienceHandler, AddStandardHedgingHandler, ResiliencePipelineBuilder for static clients, Refit integration, and the ResilienceHandler wrapper. Trigger whenever the user writes, reviews, or asks about HTTP resilience, retries, circuit breakers, timeouts, transient fault handling, AddStandardResilienceHandler, AddResilienceHandler, AddStandardHedgingHandler, Polly, IHttpClientFactory resilience, or HttpClient reliability in .NET — even if they don't mention Microsoft.Extensions.Http.Resilience by name. Always prefer this skill over guessing; the handler-stacking rules, retry safety for non-idempotent methods, TimeoutRejectedException vs TimeoutException distinction, and static-client wiring all have non-obvious failure modes. Also trigger when the deprecated Microsoft.Extensions.Http.Polly package is used — it should be replaced with this package.
4+
---
5+
6+
# Microsoft.Extensions.Http.Resilience Skill
7+
8+
`Microsoft.Extensions.Http.Resilience` is the official .NET integration layer between `IHttpClientFactory` and Polly v8 resilience pipelines. It replaces the deprecated `Microsoft.Extensions.Http.Polly` package and provides first-class DI support for retry, circuit breaker, timeout, rate limiting, and hedging.
9+
10+
**NuGet**: `<PackageReference Include="Microsoft.Extensions.Http.Resilience" />`
11+
12+
## Quick reference
13+
14+
| Topic | See |
15+
|-------|-----|
16+
| `AddStandardResilienceHandler`, defaults pipeline, DisableFor, global defaults | [standard-handler.md](references/standard-handler.md) |
17+
| `AddStandardHedgingHandler`, routing strategies, SelectPipelineByAuthority | [hedging-handler.md](references/hedging-handler.md) |
18+
| `AddResilienceHandler` custom pipelines, HttpRetryStrategyOptions, dynamic reload | [custom-pipeline.md](references/custom-pipeline.md) |
19+
| `ResiliencePipelineBuilder` + `ResilienceHandler` for static/singleton clients | [static-clients.md](references/static-clients.md) |
20+
| Refit integration (DI-registered and manually-constructed) | [refit-integration.md](references/refit-integration.md) |
21+
22+
## Two approaches
23+
24+
### 1. IHttpClientFactory-based (recommended)
25+
26+
Wire resilience into the `IHttpClientBuilder` chain during DI registration. This is the idiomatic approach for typed or named clients.
27+
28+
```csharp
29+
// Typed client — single method, all defaults
30+
builder.Services
31+
.AddHttpClient<MyApiClient>(c => c.BaseAddress = new("https://api.example.com"))
32+
.AddStandardResilienceHandler();
33+
34+
// Named client — custom pipeline
35+
services.AddHttpClient("payments")
36+
.AddResilienceHandler("PaymentsPipeline", pipeline =>
37+
{
38+
pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 2 });
39+
pipeline.AddTimeout(TimeSpan.FromSeconds(10));
40+
});
41+
```
42+
43+
### 2. Static / manually-constructed HttpClient
44+
45+
When you construct `HttpClient` directly (e.g., Refit with scoped DI, or a singleton not owned by DI), build the pipeline and wrap the handler chain manually. See [static-clients.md](references/static-clients.md).
46+
47+
## Critical rules
48+
49+
- **Never stack resilience handlers.** Only add _one_ resilience handler per client. If you need multiple strategies combine them inside a single `AddResilienceHandler` call.
50+
- **Retry is unsafe for non-idempotent methods.** Always call `options.Retry.DisableForUnsafeHttpMethods()` (or `DisableFor(HttpMethod.Post, ...)`) when POST/PUT/DELETE mutate state.
51+
- **Timeout exception type.** Polly throws `TimeoutRejectedException`, not `TimeoutException`. When writing `ShouldHandle` predicates in a retry that sits outside a timeout, handle `TimeoutRejectedException`.
52+
- **Circuit breaker per authority.** When using a circuit breaker on named clients shared across multiple host names, call `.SelectPipelineByAuthority()` so each host gets its own breaker state.
53+
- **`Microsoft.Extensions.Http.Polly` is deprecated.** Replace any `AddPolicyHandler` / `AddTransientHttpErrorPolicy` calls with the APIs in this skill.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Custom Resilience Pipelines
2+
3+
Use `AddResilienceHandler` when you need full control over which strategies run and in what order. You get the same Polly building blocks as the standard handler, but assembled manually.
4+
5+
## Basic shape
6+
7+
```csharp
8+
services.AddHttpClient<PaymentsClient>(c => c.BaseAddress = new("https://pay.example.com"))
9+
.AddResilienceHandler("PaymentsPipeline", pipeline =>
10+
{
11+
// Order matters: outermost strategy listed first
12+
pipeline.AddRetry(new HttpRetryStrategyOptions
13+
{
14+
MaxRetryAttempts = 2,
15+
BackoffType = DelayBackoffType.Exponential,
16+
UseJitter = true,
17+
Delay = TimeSpan.FromMilliseconds(500),
18+
// Safe for idempotent GETs, but be careful with mutations
19+
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
20+
.HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.RequestTimeout)
21+
.Handle<HttpRequestException>()
22+
});
23+
24+
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
25+
{
26+
SamplingDuration = TimeSpan.FromSeconds(30),
27+
FailureRatio = 0.5,
28+
MinimumThroughput = 5,
29+
BreakDuration = TimeSpan.FromSeconds(30),
30+
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
31+
.HandleResult(r => (int)r.StatusCode >= 500)
32+
.Handle<HttpRequestException>()
33+
});
34+
35+
pipeline.AddTimeout(TimeSpan.FromSeconds(15));
36+
});
37+
```
38+
39+
## HttpRetryStrategyOptions key properties
40+
41+
| Property | Description | Default |
42+
|----------|-------------|---------|
43+
| `MaxRetryAttempts` | Number of retries (not attempts) | 3 |
44+
| `BackoffType` | `Constant`, `Linear`, `Exponential` | `Exponential` |
45+
| `Delay` | Base delay between attempts | 2 s |
46+
| `UseJitter` | Add randomness to spread retries | `true` |
47+
| `ShouldHandle` | What to retry on | 5xx, 408, 429, `HttpRequestException`, `TimeoutRejectedException` |
48+
49+
## HttpCircuitBreakerStrategyOptions key properties
50+
51+
| Property | Description | Default |
52+
|----------|-------------|---------|
53+
| `FailureRatio` | Fraction of failures to trip breaker | 0.1 (10 %) |
54+
| `MinimumThroughput` | Min requests before ratio applies | 100 |
55+
| `SamplingDuration` | Sliding window | 30 s |
56+
| `BreakDuration` | How long breaker stays open | 5 s |
57+
58+
## TimeoutRejectedException gotcha
59+
60+
When a retry wraps a timeout, Polly raises `TimeoutRejectedException` (not `TimeoutException`) when the attempt times out. If your `ShouldHandle` doesn't include it, the retry won't fire:
61+
62+
```csharp
63+
// Wrong — TimeoutException is NOT what Polly throws
64+
.Handle<TimeoutException>()
65+
66+
// Correct
67+
.Handle<TimeoutRejectedException>()
68+
// or just don't specify Handle<> for it — HttpRetryStrategyOptions handles it by default
69+
```
70+
71+
Using `HttpRetryStrategyOptions` (the HTTP-specific type) rather than `RetryStrategyOptions<HttpResponseMessage>` makes this easier — it comes with sensible defaults already including `TimeoutRejectedException`.
72+
73+
## Dynamic reload from configuration
74+
75+
Use the two-argument overload to reload options at runtime without restarting:
76+
77+
```csharp
78+
services.AddHttpClient<SearchClient>()
79+
.AddResilienceHandler(
80+
"SearchPipeline",
81+
(pipeline, context) =>
82+
{
83+
// Reloads whenever IOptionsMonitor<HttpRetryStrategyOptions>("SearchRetry") changes
84+
context.EnableReloads<HttpRetryStrategyOptions>("SearchRetry");
85+
86+
var retryOptions = context.GetOptions<HttpRetryStrategyOptions>("SearchRetry");
87+
pipeline.AddRetry(retryOptions);
88+
});
89+
```
90+
91+
Configure the named options in `appsettings.json` under the matching key and bind with `services.Configure<HttpRetryStrategyOptions>("SearchRetry", config.GetSection("SearchRetry"))`.
92+
93+
## Per-authority circuit breaker
94+
95+
If a named client talks to multiple host names (via routing or redirects), isolate circuit breaker state per authority so one unhealthy host doesn't trip the breaker for others:
96+
97+
```csharp
98+
services.AddHttpClient("multi-region")
99+
.AddResilienceHandler("MultiRegionPipeline", pipeline =>
100+
{
101+
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions());
102+
})
103+
.SelectPipelineByAuthority();
104+
```
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Hedging Handler
2+
3+
Hedging is a different resilience pattern from retry: instead of waiting for a failure before retrying, it proactively issues a second (or third) request in parallel when the first one is slow. Use hedging when latency matters more than request volume.
4+
5+
## When to prefer hedging over retry
6+
7+
- **Retry**: best when the dependency is occasionally down or rate-limiting. Tolerates higher latency.
8+
- **Hedging**: best when the dependency is usually available but sometimes slow (tail latency). Doubles request volume but halves p99.
9+
10+
## Basic usage
11+
12+
```csharp
13+
services.AddHttpClient<SearchClient>(c => c.BaseAddress = new("https://search.example.com"))
14+
.AddStandardHedgingHandler();
15+
```
16+
17+
## Default hedging pipeline (outermost → innermost, per attempt)
18+
19+
| Order | Strategy | Default |
20+
|-------|----------|---------|
21+
| 1 | Total request timeout | 30 s |
22+
| 2 | Hedging | Min 1 attempt, max 10 concurrent, 2 s delay before hedging |
23+
| 3 | Rate limiter (per endpoint) | Queue: 0, Permit: 1 000 |
24+
| 4 | Circuit breaker (per endpoint) | 10 % failure ratio, 100 req minimum, 30 s window, 5 s break |
25+
| 5 | Attempt timeout (per endpoint) | 10 s |
26+
27+
The per-endpoint circuit breaker ensures one slow endpoint doesn't cascade to healthy ones.
28+
29+
## SelectPipelineByAuthority (important)
30+
31+
The circuit breaker pool is keyed by URL authority by default. Make this explicit to avoid accidentally sharing state across different base addresses:
32+
33+
```csharp
34+
services.AddHttpClient("search")
35+
.AddStandardHedgingHandler()
36+
.SelectPipelineByAuthority();
37+
```
38+
39+
## Weighted routing (A/B testing)
40+
41+
```csharp
42+
services.AddHttpClient<SearchClient>()
43+
.AddStandardHedgingHandler(builder =>
44+
{
45+
builder.ConfigureWeightedGroups(opts =>
46+
{
47+
opts.SelectionMode = WeightedGroupSelectionMode.EveryAttempt;
48+
opts.Groups.Add(new WeightedUriEndpointGroup
49+
{
50+
Endpoints =
51+
{
52+
new() { Uri = new("https://search-v2.example.com"), Weight = 10 },
53+
new() { Uri = new("https://search.example.com"), Weight = 90 }
54+
}
55+
});
56+
});
57+
});
58+
```
59+
60+
## Ordered failover
61+
62+
```csharp
63+
services.AddHttpClient<SearchClient>()
64+
.AddStandardHedgingHandler(builder =>
65+
{
66+
builder.ConfigureOrderedGroups(opts =>
67+
{
68+
opts.Groups.Add(new UriEndpointGroup
69+
{
70+
Endpoints =
71+
{
72+
new() { Uri = new("https://primary.example.com"), Weight = 97 },
73+
new() { Uri = new("https://fallback.example.com"), Weight = 3 }
74+
}
75+
});
76+
});
77+
});
78+
```
79+
80+
The maximum number of hedging attempts equals the number of configured endpoint groups.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Refit Integration
2+
3+
Refit clients can be wired with resilience in two ways depending on whether they are managed by `IHttpClientFactory` (cleaner, recommended) or constructed manually (needed when clients are scoped).
4+
5+
## IHttpClientFactory-based (recommended)
6+
7+
Use `AddRefitClient<T>()` from `Refit.HttpClientFactory`, then chain `AddResilienceHandler` or `AddStandardResilienceHandler` exactly like any other typed client:
8+
9+
```csharp
10+
// Simple — standard resilience, no custom options
11+
services
12+
.AddRefitClient<IOrdersClient>()
13+
.ConfigureHttpClient(c => c.BaseAddress = new("https://orders.example.com"))
14+
.AddStandardResilienceHandler(opts =>
15+
{
16+
opts.Retry.DisableForUnsafeHttpMethods(); // POST/PUT/DELETE not idempotent
17+
});
18+
19+
// Custom pipeline
20+
services
21+
.AddRefitClient<ISearchClient>()
22+
.ConfigureHttpClient(c => c.BaseAddress = new("https://search.example.com"))
23+
.AddResilienceHandler("SearchPipeline", pipeline =>
24+
{
25+
pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 5 });
26+
pipeline.AddTimeout(TimeSpan.FromSeconds(8));
27+
});
28+
```
29+
30+
## Manually-constructed Refit (Blazor scoped pattern)
31+
32+
In Blazor Server you often need scoped clients that capture per-circuit services (auth tokens, tenant context). Use the static-client pattern from [static-clients.md](static-clients.md) and pass the final `HttpClient` to `RestService.For<T>`:
33+
34+
```csharp
35+
services.AddScoped<IOrdersClient>(sp =>
36+
{
37+
var pipeline = sp.GetRequiredService<ResiliencePipeline<HttpResponseMessage>>();
38+
var tokenService = sp.GetRequiredService<TokenService>();
39+
var tenantService = sp.GetRequiredService<TenantService>();
40+
41+
// Handler chain: Resilience → Auth → Tenant → Network
42+
var networkHandler = new HttpClientHandler();
43+
var tenantHandler = new TenantHeaderHandler(tenantService) { InnerHandler = networkHandler };
44+
var authHandler = new AuthHandler(tokenService) { InnerHandler = tenantHandler };
45+
var resilienceHandler = new ResilienceHandler(pipeline) { InnerHandler = authHandler };
46+
47+
var httpClient = new HttpClient(resilienceHandler) { BaseAddress = new("https://api.example.com") };
48+
return RestService.For<IOrdersClient>(httpClient);
49+
});
50+
```
51+
52+
The shared `ResiliencePipeline<HttpResponseMessage>` is registered as a singleton (built once at startup) and injected. See [static-clients.md](static-clients.md) for how to build and register it.
53+
54+
## Choosing between the two
55+
56+
| Scenario | Approach |
57+
|----------|----------|
58+
| Standalone API / worker / console app | `AddRefitClient<T>().AddStandardResilienceHandler()` |
59+
| Blazor Server with per-circuit auth/tenant | Manual `RestService.For<T>(httpClient)` with `ResilienceHandler` |
60+
| Multiple clients sharing same pipeline | Build shared `ResiliencePipeline<HttpResponseMessage>` singleton |
61+
| Clients needing different resilience options | Separate `AddResilienceHandler` calls per client |
62+
63+
## Common mistake: registering scoped Refit clients as singletons
64+
65+
If you register a Refit client as `AddSingleton` but it captures a scoped `TokenService`, you'll get a captive dependency bug — the first request claims a token and every subsequent request uses the same stale token. Always register as `AddScoped` when the client captures scoped services.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Standard Resilience Handler
2+
3+
`AddStandardResilienceHandler()` wires five Polly strategies in a sensible default order with out-of-the-box settings that cover most APIs. It is the right starting point unless you specifically need hedging or a hand-tuned pipeline.
4+
5+
## Default pipeline (outermost → innermost)
6+
7+
| Order | Strategy | What it does | Default |
8+
|-------|----------|-------------|---------|
9+
| 1 | Rate limiter | Caps concurrent requests | Queue: 0, Permit: 1 000 |
10+
| 2 | Total timeout | Overall time limit including retries | 30 s |
11+
| 3 | Retry | Retries on transient errors | 3 attempts, exponential backoff + jitter, 2 s base |
12+
| 4 | Circuit breaker | Opens on sustained failures | 10 % failure ratio, min 100 requests, 30 s window, 5 s break |
13+
| 5 | Attempt timeout | Per-attempt time limit | 10 s |
14+
15+
**Handled by retry and circuit breaker**: HTTP 5xx, 408, 429, `HttpRequestException`, `TimeoutRejectedException`.
16+
17+
## Basic usage
18+
19+
```csharp
20+
// Typed client
21+
services.AddHttpClient<WeatherClient>(c => c.BaseAddress = new("https://api.weather.com"))
22+
.AddStandardResilienceHandler();
23+
24+
// Named client
25+
services.AddHttpClient("external-api")
26+
.AddStandardResilienceHandler();
27+
```
28+
29+
## Customising options
30+
31+
Pass a delegate to override individual strategy options without replacing the whole pipeline:
32+
33+
```csharp
34+
services.AddHttpClient<OrdersClient>(c => c.BaseAddress = new("https://orders.example.com"))
35+
.AddStandardResilienceHandler(options =>
36+
{
37+
// Tighten the total timeout for latency-sensitive callers
38+
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
39+
40+
// Fewer retries for this client
41+
options.Retry.MaxRetryAttempts = 2;
42+
43+
// Don't retry writes — risks duplicate records
44+
options.Retry.DisableForUnsafeHttpMethods();
45+
46+
// Tighter circuit breaker
47+
options.CircuitBreaker.FailureRatio = 0.3;
48+
options.CircuitBreaker.MinimumThroughput = 20;
49+
});
50+
```
51+
52+
### DisableFor vs DisableForUnsafeHttpMethods
53+
54+
- `DisableFor(HttpMethod.Post, HttpMethod.Delete)` — explicit list
55+
- `DisableForUnsafeHttpMethods()` — disables retries for POST, PUT, PATCH, DELETE, CONNECT per RFC 7231 idempotency rules
56+
57+
Always use one of these when the API performs state mutations.
58+
59+
## Global defaults
60+
61+
Apply a resilience handler to all registered `HttpClient` instances in one go, then selectively override:
62+
63+
```csharp
64+
// All clients get standard resilience
65+
services.ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler());
66+
67+
// High-priority client: swap to hedging and remove the standard handler
68+
services.AddHttpClient("priority")
69+
.RemoveAllResilienceHandlers()
70+
.AddStandardHedgingHandler();
71+
```
72+
73+
`RemoveAllResilienceHandlers()` clears every previously registered resilience handler, giving you a clean slate. Use it after `ConfigureHttpClientDefaults` when a specific client needs different behaviour.
74+
75+
## Aspire integration
76+
77+
When using .NET Aspire service defaults (`AddServiceDefaults`), resilience is pre-configured via `ConfigureHttpClientDefaults`. Review [BookStore.ServiceDefaults/AGENTS.md](../../../../src/BookStore.ServiceDefaults/AGENTS.md) to understand what's already wired in before adding another handler.

0 commit comments

Comments
 (0)