What Changed in HttpClient and Resilience: From Microsoft.Extensions.Http.Polly to Http.Resilience, Polly v7 to v8 and .NET 11
Microsoft.Extensions.Http.Resilience replaced Microsoft.Extensions.Http.Polly, and in Polly v8 policies became a single ResiliencePipeline; old code can be migrated step by step with the AsAsyncPolicy bridge. .NET 11 adds request compression, an experimental connection eviction callback and typed DNS records.
Version note: The code in this post was built and run with .NET 10, Polly 8.9, Microsoft.Extensions.Http.Resilience 10.11 and Microsoft.Extensions.Http.Polly 10.0; the .NET 11 samples were built and run with the .NET 11 SDK 11.0.100.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, was written with the newest APIs throughout this series. In real life, though, you often inherit integration code written years ago. Three questions:
First: your project references Microsoft.Extensions.Http.Polly. Does dotnet list package --deprecated list this package as deprecated?
Second: you upgraded Polly from 7.2 to 8.9. Does v7 code such as Policy.Handle<HttpRequestException>().WaitAndRetryAsync(...) still compile?
Third: is the SocketsHttpHandler.ShouldEvictConnection callback in .NET 11 called before every request?
The answers are "no", "yes" and "no", and they come from the real runs below. In this last post we look at what came with which version on the HttpClient and resilience side, learn to recognize old code and migrate it step by step, and then run what .NET 11 brings.
A Short History: What Came With Which Version
| Version | What arrived | Where it is in this series |
|---|---|---|
| .NET Core 2.1 (2018) | IHttpClientFactory; AddPolicyHandler with Polly v7 (Microsoft.Extensions.Http.Polly) | Post 02; the migration in this post |
| .NET 6 | WebRequest, HttpWebRequest, WebClient obsolete (SYSLIB0014) | Recognizing old code |
| Polly 8.0 (September 2023) | ResiliencePipeline, Polly.Core, telemetry | Post 04 |
| .NET 8 (November 2023) | Microsoft.Extensions.Http.Resilience 8.0, ConfigureHttpClientDefaults, HttpClient metrics | Posts 02, 03 and 05 |
| Polly 8.3 (February 2024) | Simmy's chaos strategies inside Polly | Post 05 |
| .NET 9 | Keyed clients (AddAsKeyed), SocketsHttpHandler as the factory default, header values hidden in logs | Post 02 |
| .NET 11 (November 2026) | Request compression, a connection eviction callback, InitialHttp2MaxConcurrentStreams, typed DNS records | The last section of this post |
I took the dates from NuGet's publication records: the first stable Microsoft.Extensions.Http, 2.1.0, came out in May 2018, Polly 8.0.0 on 28 September 2023, and Microsoft.Extensions.Http.Resilience 8.0.0 on the same day as .NET 8, 14 November 2023. I also verified by compiling that ConfigureHttpClientDefaults came with .NET 8: the same code fails with CS1061 against Microsoft.Extensions.Http 7.0.0 and compiles against 8.0.0.
Two .NET 9 changes surprise people moving old code forward the most. The factory now uses SocketsHttpHandler instead of HttpClientHandler as the primary handler and sets its PooledConnectionLifetime to HandlerLifetime; code that casts the primary handler to HttpClientHandler gets an InvalidCastException. And header values in the factory's logs are now hidden as * by default unless RedactLoggedHeaders is called. We saw both with real output in post 02.
Recognizing Old Code
If an integration you inherit has one of these traces, the code most likely dates from before .NET Core 2.1 or from the Polly v7 era:
WebRequest.Create,HttpWebRequest,WebClient: APIs from the .NET Framework days.using var client = new HttpClient()on every call: a socket exhaustion risk (post 02).AddPolicyHandler,AddTransientHttpErrorPolicy,HttpPolicyExtensions.HandleTransientHttpError(): Microsoft.Extensions.Http.Polly.Policy.WrapAsync,PolicyRegistry,IAsyncPolicy<HttpResponseMessage>: the Polly v7 API.
The compiler already tells you about the first one. Compiling a small file with WebRequest.Create and new WebClient() on .NET 10:
Program.cs(3,31): warning SYSLIB0014: 'WebRequest.Create(string)' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.' (https://aka.ms/dotnet-warnings/SYSLIB0014) [Old.csproj]
Program.cs(5,17): warning SYSLIB0014: 'WebClient.WebClient()' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.' (https://aka.ms/dotnet-warnings/SYSLIB0014) [Old.csproj]
These warnings have existed since .NET 6. One more important detail: settings on ServicePointManager (connection limit, TLS version, DNS refresh time) no longer affect HttpClient. If old code solved a problem with those settings, look for the equivalent among SocketsHttpHandler's properties.
From Microsoft.Extensions.Http.Polly to Http.Resilience
The description on Microsoft.Extensions.Http.Polly's NuGet page says the package is deprecated and recommends Microsoft.Extensions.Resilience or Microsoft.Extensions.Http.Resilience instead. Yet the package still ships with every .NET release (its latest version, 11.0.0, came out the same day as .NET 11), and it doesn't carry NuGet's formal deprecation flag. That is why the first answer is "no". In the Bookstore.ResilienceLab project, where the migration demos live:
dotnet list samples/Bookstore.ResilienceLab package --deprecated --include-transitive
Project `Bookstore.ResilienceLab` has the following deprecated packages
[net10.0]:
Transitive Package Resolved Reason(s) Alternative
> Polly.Extensions.Http 3.0.0 Legacy Microsoft.Extensions.Http.Resilience >= 0.0.0
The list also had a few other transitive packages brought in by WireMock.Net; I kept the line about Polly. The flag shows up not on Microsoft.Extensions.Http.Polly itself but on its dependency Polly.Extensions.Http 3.0.0, which points to Http.Resilience as the alternative. So someone looking without --include-transitive sees no warning at all. The package depends on Polly 7.2.4 or later; it works with 8.x too (these demos ran on Polly 8.9.1), but because it uses the v7 API, moving to Polly.Core only means removing it.
I registered the same carrier client in both ways. The old one first:
// Before: AddPolicyHandler with a v7 policy, as in many .NET Core 2.1-7 code bases
services.AddHttpClient("legacy-carrier", c => c.BaseAddress = server.Url)
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(2, attempt => TimeSpan.FromMilliseconds(200 * attempt),
onRetry: (outcome, delay, attempt, _) =>
Console.WriteLine($"v7 retry {attempt} after {(int?)outcome.Result?.StatusCode}, waiting {delay.TotalMilliseconds} ms")))
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(2)));
Then the new one:
// After: one resilience handler, strategies added from the outside in
services.AddHttpClient("legacy-carrier", c => c.BaseAddress = server.Url)
.AddResilienceHandler("carrier", pipeline => pipeline
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Linear,
Delay = TimeSpan.FromMilliseconds(200),
UseJitter = false,
OnRetry = args =>
{
Console.WriteLine($"v8 retry {args.AttemptNumber + 1} after {(int?)args.Outcome.Result?.StatusCode}, waiting {args.RetryDelay.TotalMilliseconds} ms");
return default;
}
})
.AddTimeout(TimeSpan.FromSeconds(2)));
Both demos talk to the same fake server that returns 500 twice, then 200:
== migration-v7
v7 retry 1 after 500, waiting 200 ms
v7 retry 2 after 500, waiting 400 ms
v7 -> 200 after 3 attempts
== migration-v8
v8 retry 1 after 500, waiting 200 ms
v8 retry 2 after 500, waiting 400 ms
v8 -> 200 after 3 attempts
The behavior is the same, but there are a few differences that slip through during migration. In v7, two AddPolicyHandler calls mean two separate DelegatingHandlers; the first one added is the outer one. In v8 there is one handler and the order is the builder's order, which is the "first added is outermost" rule from post 04. HandleTransientHttpError() handles 5xx, 408 and HttpRequestException; the default ShouldHandle of HttpRetryStrategyOptions adds 429 and the timeout exception. The attempt counter in v7 starts at 1, AttemptNumber in v8 at 0. And I turned jitter off on purpose in the new code so the outputs could be compared. In production, leave jitter, v8's default, on.
When you migrate old code, consider the standard pipeline from post 03 instead of a one-to-one translation. Most old projects already combine timeout, retry and circuit breaker, and AddStandardResilienceHandler gives you vetted defaults for all of them in one line.
Polly v7 to v8: Where the Concepts Went
Leaving Http.Polly is part of leaving the v7 API. The mappings from Polly's migration guide you'll meet most often:
| v7 | v8 |
|---|---|
IAsyncPolicy, ISyncPolicy and their generic forms | ResiliencePipeline, ResiliencePipeline<T> |
Policy.WrapAsync(...) | Consecutive Add* calls on the builder |
WaitAndRetryAsync | AddRetry with RetryStrategyOptions |
Policy.BulkheadAsync | AddConcurrencyLimiter |
| Advanced circuit breaker | AddCircuitBreaker; no classic (consecutive-failure) breaker |
Isolate(), Reset() | CircuitBreakerManualControl.IsolateAsync(), CloseAsync() |
Context | ResilienceContext and ResiliencePropertyKey<T> |
PolicyRegistry | ResiliencePipelineRegistry, ResiliencePipelineProvider for reading |
The biggest difference is in the philosophy: in v7 every policy was an independent object and you combined them with Wrap; in v8 there is one pipeline and strategies are its parts. Telemetry (logs and metrics), TimeProvider support and DI integration also come at the pipeline level in v8; that is how all the metrics in post 05 appeared without extra code.
The second answer from the guessing section is in Polly's package layout. In v8, Polly.Core contains only the new API, while the Polly package carries both Polly.Core and the v7 API. That is why the guide recommends upgrading the Polly package to 8.x first, migrating policies over time, and switching to Polly.Core once the old code is gone. The missing classic circuit breaker is one of the rare places where migration changes behavior: a breaker built around "5 failures in a row" has to be rethought in v8 as a failure ratio and a minimum number of requests.
Gradual Migration: A v8 Strategy in a v7 Chain
Migrating a large code base in a single pull request isn't realistic. Polly v8 offers a bridge for this: AsAsyncPolicy() turns a ResiliencePipeline into a v7 IAsyncPolicy that you can put into an old Policy.WrapAsync chain. In the demo below, a newly written v8 circuit breaker runs on the same client as a v7 retry policy that hasn't been migrated yet:
// In between: a new v8 circuit breaker inside an old v7 policy chain, while the rest is migrated
var breaker = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
MinimumThroughput = 3,
BreakDuration = TimeSpan.FromSeconds(30)
})
.Build()
.AsAsyncPolicy();
var retry = HttpPolicyExtensions.HandleTransientHttpError()
.Or<BrokenCircuitException>()
.RetryAsync(5, onRetry: (outcome, attempt) =>
Console.WriteLine($"v7 retry {attempt}: {(outcome.Exception is { } ex ? ex.GetType().Name : ((int)outcome.Result.StatusCode).ToString())}"));
services.AddHttpClient("legacy-carrier", c => c.BaseAddress = server.Url)
.AddPolicyHandler(Policy.WrapAsync(retry, breaker));
The server returns 500 to every request:
v7 retry 1: 500
v7 retry 2: 500
v7 retry 3: 500
v7 retry 4: BrokenCircuitException
v7 retry 5: BrokenCircuitException
gave up: BrokenCircuitException, server saw 3 requests
The v8 breaker opened after three requests; the v7 retry made two more attempts, but the server saw only 3 requests, because the open breaker cut the calls off before they reached the network. The ordering rule applies here as well: WrapAsync(retry, breaker) puts the retry outside and the breaker inside. The demo shows one more thing: having the retry handle BrokenCircuitException too means attempts that spin uselessly against an open breaker. Review old decisions like this during the migration.
In a code base like Bookstore the order would be: first upgrade the Polly package to 8.x and see the tests pass; write everything new with v8 and, where needed, connect it to the old chain with AsAsyncPolicy(); move the clients to AddResilienceHandler or AddStandardResilienceHandler one by one; finally remove Microsoft.Extensions.Http.Polly and the Polly package and keep only Polly.Core (plus Polly.Extensions if needed). At every step the tests from post 05 are your assurance that behavior hasn't changed.
.NET 11: What's New on the HttpClient Side
.NET 11 was released on 10 November 2026, and of the networking items on the "What's new in .NET 11 libraries" page on Microsoft Learn (ms.date 10 November 2026), four relate directly to HttpClient and calls to external services. Because this series stays on .NET 10 LTS, I put the samples into a separate project: samples/Bookstore.Net11Lab asks for the .NET 11 SDK through its own global.json and is not part of Bookstore.slnx. Version 11.0 of Microsoft.Extensions.Http.Resilience shipped together with .NET 11 as well; the series stays on 10.x.
cd samples/Bookstore.Net11Lab
dotnet run -- compression
The first is request compression. GZipCompressedContent, BrotliCompressedContent and ZstandardCompressedContent wrap any HttpContent, set the Content-Encoding header and compress the content as it is sent. I sent a school's 30-line bulk order to a local endpoint that reports the bytes received and the size of the decompressed JSON:
HttpContent[] contents =
[
JsonContent.Create(order),
new GZipCompressedContent(JsonContent.Create(order), CompressionLevel.Optimal),
new BrotliCompressedContent(JsonContent.Create(order), CompressionLevel.Optimal),
new ZstandardCompressedContent(JsonContent.Create(order), CompressionLevel.Optimal)
];
JsonContent Content-Encoding: -> {"encoding":"none","wireBytes":2855,"jsonBytes":2855}
GZipCompressedContent Content-Encoding: gzip -> {"encoding":"gzip","wireBytes":347,"jsonBytes":2855}
BrotliCompressedContent Content-Encoding: br -> {"encoding":"br","wireBytes":254,"jsonBytes":2855}
ZstandardCompressedContent Content-Encoding: zstd -> {"encoding":"zstd","wireBytes":292,"jsonBytes":2855}
In JSON full of repeated fields, 2,855 bytes shrank to 254 with Brotli. The other side has to accept compressed requests, which isn't the default for most external services. On the ASP.NET Core side there has been a request decompression middleware for this since 7.0, and .NET 11 added zstd to the formats it supports. Before sending a compressed body to an external service, look for Content-Encoding support in its documentation; otherwise you'll get a 400 or a 415.
The second is SocketsHttpHandler.ShouldEvictConnection. Until now we could only limit connection lifetime with PooledConnectionLifetime, a fixed duration. The new callback sees the connection's age, remote address, DnsEndPoint and HTTP version and leaves the "drop this connection" decision to you; for example, to close open connections to an endpoint whose DNS record changed. The first build gave me this error:
samples/Bookstore.Net11Lab/Program.cs(97,9): error SYSLIB5008: 'System.Net.Http.SocketsHttpHandler.ShouldEvictConnection' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. (https://aka.ms/dotnet-warnings/SYSLIB5008) [samples/Bookstore.Net11Lab/Bookstore.Net11Lab.csproj]
The property is marked [Experimental] in .NET 11, and using such an API is a compile error by default; the error itself says you can't proceed without suppressing it. I suppressed it on purpose, only for those lines:
var handler = new SocketsHttpHandler
{
// .NET 11: decide per connection instead of a fixed PooledConnectionLifetime
// Experimental in 11.0 (SYSLIB5008): the shape may still change
#pragma warning disable SYSLIB5008
ShouldEvictConnection = (context, _) =>
{
var evict = context.Age > TimeSpan.FromSeconds(1);
Console.WriteLine($" check connection {context.ConnectionId}, HTTP/{context.HttpVersion}, age {context.Age.TotalMilliseconds:0} ms -> evict: {evict}");
return Task.FromResult(evict);
},
#pragma warning restore SYSLIB5008
// .NET 11: limit the first burst on a new HTTP/2 connection until the server's SETTINGS arrive
InitialHttp2MaxConcurrentStreams = 20
};
I sent three requests three seconds apart. I expected the callback to run on every request; it didn't:
[0.0 s] request 1: {"connection":"0HNQ26UERUQHD"}
[3.0 s] request 2: {"connection":"0HNQ26UERUQHD"}
check connection 0, HTTP/1.1, age 4999 ms -> evict: True
[6.0 s] request 3: {"connection":"0HNQ26UERUQHE"}
The second request used the connection, already older than 1 second, without a check. The callback ran at second 5, when the pool periodically went over its idle connections; the connection was dropped and the third request opened a new one. In a separate run, lowering PooledConnectionIdleTimeout to 4 seconds brought the checks down to about once a second. The API documentation doesn't describe this timing and the property is experimental; measure it in your own scenario before tying production code to this behavior.
The third is InitialHttp2MaxConcurrentStreams. On a new HTTP/2 connection it limits how many streams the client opens at once until the server announces its own limit in a SETTINGS frame; the default is 100. If you send hundreds of requests the moment a connection to a provider with a known low limit opens, this softens that first wave. Our local server speaks HTTP/1.1, so I didn't measure it; I only showed that it compiles and can be set.
The fourth is typed DNS records. Methods such as Dns.ResolveMxAsync, ResolveSrvAsync and ResolveTxtAsync return records together with their TTL and the response code; the DnsResolver class lets you supply your own list of DNS servers. In Bookstore it could be used to check, when an institutional customer registers, whether their e-mail domain actually accepts mail:
MX example.org: NoError, records: 1, negative cache TTL: 00:00:00
preference 0, exchange '.', ttl 00:02:43
MX no-such-school.example: NxDomain, records: 0, negative cache TTL: 00:14:01
The single MX record of example.org points to .: this "null MX" says the domain accepts no e-mail at all. The non-existent domain came back as NxDomain with a negative cache duration. You couldn't get this information from Dns.GetHostAddresses before; it took a separate DNS library.
In Production
Take experimental APIs to production deliberately. Suppress a diagnostic such as SYSLIB5008 only on the lines that use the API, not for the whole project; when you move to the next .NET version you'll need to review those lines one by one.
Catch behavior differences in migration with tests. Moving from v7 to v8 can change the status codes the retry handles, where counters start, jitter and the type of breaker. Tests that count the requests the other side sees, like the ones in post 05, make these differences visible in the pull request.
Turn request compression on after measuring. For small bodies the CPU cost of compression can exceed the gain; for large bulk bodies full of repeated fields, a reduction close to tenfold, as above, is possible.
Common Pitfalls
Looking only at direct references. For Microsoft.Extensions.Http.Polly the warning only appears with --include-transitive, and through its dependency.
Assuming the v7 code is gone once Polly is on 8.x. The Polly package keeps carrying the v7 API; because the code compiles, it looks as if the migration were done.
Reversing policy order while migrating. WrapAsync(a, b) and adding a first on the builder give the same order; check the order when you merge two separate AddPolicyHandler calls into one AddResilienceHandler.
Trying to migrate the classic circuit breaker one to one. It has no v8 equivalent; redesign the thresholds as a failure ratio and a minimum number of requests.
A Small Experiment
★☆☆ Run dotnet list package --deprecated --include-transitive in your own project. Which lines come from your direct references and which from transitive dependencies?
★★☆ Rewrite the retry in the migration-v7 demo with AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(...)), then move it to AddStandardResilienceHandler. How does the behavior of the two versions differ when the fake server returns 429?
★★★ Write the ShouldEvictConnection callback in Bookstore.Net11Lab so that it drops a connection when context.RemoteEndPoint is no longer among the current IPs of the host name in context.DnsEndPoint (hint: Dns.ResolveAddressesAsync). How would you limit the cost of a DNS query inside the callback?
This post ends the series. Bookstore's code that talks to its payment provider, its carrier and its institutional customers is now time-bounded, idempotent, tested and observable, and code inherited from the past can be moved to the new APIs step by step. The next step in talking to external services is not calling them inside the user's request at all wherever possible: take the order, then run payment, invoicing and shipping as messages. You'll find the infrastructure for that in the Working with Message Queues series and the design of such flows in the Messaging and Event-Driven Architecture series.