-
Notifications
You must be signed in to change notification settings - Fork 4.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Make SocketsHttpHandler the default primary handler from HttpClientFactory #101808
Conversation
…ctory Today the default handler is HttpClientHandler, which is just a wrapper around SocketsHttpHandler on platforms where SocketsHttpHandler is supported. This means that the configuration possible on SocketsHttpHandler isn't available as part of the default handling, which means consumers get the default PooledConnectionLifetime of infinite. The lack of that was one of the main motivations behind HttpClientFactory's handler lifetime and handler recycling. Instead of constructing an HttpClientHandler as the default handler, we can construct a SocketsHttpHandler as the default handler, and we can set its PooledConnectionLifetime to match the HttpClientFactoryOptions.HandlerLifetime, whether its default of 2 minutes or whatever a user configured it to be. This in turn means that if code gets and holds on to a handler/client from the factory for a prolonged period of time, it's still getting the connection recycling according to its configured options.
Tagging subscribers to this area: @dotnet/ncl |
Sorry I was on vacation; I plan to review this today or tomorrow. I remember thinking about the similar change but I don't remember from the top of my head what stopped me from proceeding with it. Maybe I intended to completely solve #35987 and it was not straightforward. Or maybe the fact that this is technically a breaking change. I'll comment when I brush up my memory 😄 |
@CarnaViire, did you have any other thoughts on this? Thanks. |
4a6dad4
to
3f3d2a8
Compare
The performance concern I had: by setting PooledConnectionLifetime to the same value as HandlerLifetime, we are increasing the number of created connections. In addition, the connections that were newly created in the SocketsHttpHandler just as it was retired by the HttpClientFactory, would go to waste. However, when I measure RPS with a slightly modified HttpClient benchmark (from aspnet/benchmarks), the difference seems negligible for small GET requests, and, quite surprisingly, for HTTP/1.1 GET 1Mb, RPS is even higher for the combination of PooledConnectionLifetime + HandlerLifetime than on just the HandlerLifetime rotation. I'm not sure I have an explanation for that atm. For HTTP/2.0 the difference is minimal even for GET 1Mb. HttpClientFactory (HL=5s) vs (HL=5s, PCL=5s)// HL = HandlerLifetime (HttpClientFactory rotation) .NET Core SDK Version = 8.0.301 Example parameters:
HTTP/1.1 GET 128b
HTTP/2.0 GET 128b
HTTP/1.1 GET 1Mb
HTTP/2.0 GET 1Mb
UPD: JFYI the the effect of HttpClientFactory overhead compared to a static HttpClient:
As an example:
|
public override HttpMessageHandler PrimaryHandler { get; set; } = new HttpClientHandler(); | ||
public override HttpMessageHandler PrimaryHandler | ||
{ | ||
get => _primaryHandler ??= CreatePrimaryHandler(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had concerns that there are certain workarounds like the one existing in gRPC, where the user sets the handler explicitly to null!
(it was also discussed a bit here https://github.com/dotnet/runtime/pull/90272/files#r1293537394)
I'll try to use grep app to check whether there are any other users with similar hacks; and I'll also chat with @JamesNK when he's back to see whether he would be able to change the hack to e.g. ConfigureHttpClientDefaults
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So you don't need to worry about it anymore: grpc/grpc-dotnet#2445
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @JamesNK!
{ | ||
SocketsHttpHandler handler = new(); | ||
|
||
if (Services.GetService<IOptionsMonitor<HttpClientFactoryOptions>>() is IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need to think more about this part. I need to check whether it would give us an expected result in all circumstances. Also I need to make sure the _name
is also always set to an expected value.
I'll return to it tomorrow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I still don't have an answer to that. It's in my pipeline, I will reply by Monday.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, so the answers:
_name
is set in the default factory right after creating the instance and before any of the actions are applied to the builder. So we're ok from this regard.- getting the options from IOptionsMonitor here in this point in time (while creating the handler pipeline) also aligns with the options usage in other places, including the default factory, so it's ok as well.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stephentoub Is the check is IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor
only needed to verify whether the options could be resolved from the container in general? If so, the check is redundant, since the instance of DefaultHttpClientFactory
was already created by this point, so IOptionsMonitor<HttpClientFactoryOptions>
was already successfully injected into its constructor.
I'd vote for simply injecting IOptionsMonitor<HttpClientFactoryOptions>
into DefaultHttpMessageHandlerBuilder
's constructor here as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the check is IOptionsMonitor optionsMonitor only needed to verify whether the options could be resolved from the container in general?
Mainly*, yes. It's guaranteed to have been registered? Where does that happen?
*Since IServiceProvider was being injected into the constructor rather than individual resources, this also seemed to be the more consistent approach, but I can change it if desired.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since IServiceProvider was being injected into the constructor
It's there because we need to flow it to configuration callbacks, bc we don't really know what services the callbacks will need at this point.
It's guaranteed to have been registered? Where does that happen?
Yes, it happens in the "base" AddHttpClient()
method which is called from all other AddHttpClient
methods:
Line 31 in c6bfb06
services.AddOptions(); |
which in turn has
runtime/src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs
Line 28 in c6bfb06
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>))); |
Constructor injection will align with the other usages in
IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor, |
and
Line 23 in c6bfb06
public LoggingHttpMessageHandlerBuilderFilter(IServiceProvider serviceProvider, IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor) |
So I believe it will be better to do it the same way, that being said -- I don't want to hold back this PR only for that change; I can do it myself later as a follow-up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I believe it will be better to do it the same way, that being said -- I don't want to hold back this PR only for that change; I can do it myself later as a follow-up.
Ok, thanks. I started making the change, but it ends up adding a lot more ifdefs (only wanting a field for the options if this NET, only having the ctor parameter if it's NET, etc.), so I'll leave it up to you as a follow-up if you decide you still want to go that route.
On a side note, re: breaking change. Any code that relied on the fact that the default handler was HttpClientHandler will break. For example, we even have such a code in the tests: Lines 1381 to 1384 in 0cc0791
(the only reason the test didn't fail is that it uses a mock of HttpMessageHandlerBuilder instead of a real one) |
Are we concerned about this actually affecting real code? I'd have thought we'd actively discourage folks relying on the pipeline defaulting to a specific never-changing configuration where they could downcast without fear of exception. That's similar to saying a virtual method typed to return Base and happens to actually return Derived1 today can't be changed to return Derived2 tomorrow (and we do that). |
Thinking more about this, I agree. The fact that |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks!! 🥳
Contributes to #35987 (this makes SocketsHttpHandler the default handler but it doesn't disable or push down the higher-level rotation)
@CarnaViire, how do you feel about this change?
Today the default handler is HttpClientHandler, which is just a wrapper around SocketsHttpHandler on platforms where SocketsHttpHandler is supported. This means that the configuration possible on SocketsHttpHandler isn't available as part of the default handling, which means consumers get the default PooledConnectionLifetime of infinite. The lack of that was one of the main motivations behind HttpClientFactory's handler lifetime and handler recycling. Instead of constructing an HttpClientHandler as the default handler, we can construct a SocketsHttpHandler as the default handler, and we can set its PooledConnectionLifetime to match the HttpClientFactoryOptions.HandlerLifetime, whether its default of 2 minutes or whatever a user configured it to be. This in turn means that if code gets and holds on to a handler/client from the factory for a prolonged period of time, it's still getting the connection recycling according to its configured options.