Steeltoe
Steeltoe copied to clipboard
Static HttpClient should use SocketsHttpHandler with a set pooled connection lifetime
As a follow-up to #21, any long-lived HttpClient should be configured with a pooled connection lifetime so that DNS records are less likely to be stale:
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(15) // Recreate every 15 minutes
};
var sharedClient = new HttpClient(handler);
https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines#dns-behavior
We shouldn't have long-lived HttpClient instances in the first place. They won't be able to reflect configuration changes, such as setting a proxy, changing timeouts (throws after the first request has been sent), or turning off certificate validation. And they don't respond to DNS changes. But we should certainly not create a new instance all the time either, as that exhausts available TCP ports and performs a new SSL handshake on every request.
What I think would work best is to use HttpClientFactory instead. As described here, handlers are recycled every two minutes, so we don't need to be concerned with stale DNS records. Whenever Steeltoe needs to perform an HTTP request, it obtains a new HttpClient instance from the factory, which reuses its associated handler. Applying configuration should be done at different places, depending on what needs to be configured.
The next example demonstrates how this can be implemented for an imaginary Steeltoe feature:
public sealed class FeatureOptions
{
// Defined at HttpClientHandler
public bool ValidateCertificates { get; set; }
// Defined at HttpRequest
public int TimeoutInSeconds { get; set; }
// Defined at HttpRequestMessage
public bool ForceHttps { get; set; }
}
public static class SteeltoeFeatureServiceCollectionExtensions
{
public static IServiceCollection AddSteeltoeFeature(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions();
services.Configure<FeatureOptions>(configuration.GetSection("Steeltoe:Feature"));
services.AddTransient<FeatureDelegatingHandler>();
var httpClientBuilder = services.AddHttpClient("Feature");
httpClientBuilder.AddHttpMessageHandler<FeatureDelegatingHandler>();
httpClientBuilder.ConfigurePrimaryHttpMessageHandler(serviceProvider =>
{
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<FeatureOptions>>();
var optionsSnapshot = optionsMonitor.CurrentValue;
Console.WriteLine($"Creating handler with ValidateCertificates = {optionsSnapshot.ValidateCertificates}.");
return new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
!optionsSnapshot.ValidateCertificates
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null
};
});
httpClientBuilder.SetHandlerLifetime(TimeSpan.FromSeconds(5));
services.AddHostedService<FeatureHostedService>();
return services;
}
}
internal sealed class FeatureDelegatingHandler : DelegatingHandler
{
private readonly IOptionsMonitor<FeatureOptions> _optionsMonitor;
public FeatureDelegatingHandler(IOptionsMonitor<FeatureOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var optionsSnapshot = _optionsMonitor.CurrentValue;
if (optionsSnapshot.ForceHttps && request.RequestUri?.Scheme == "http")
{
var builder = new UriBuilder(request.RequestUri)
{
Scheme = "https",
Port = -1
};
request.RequestUri = builder.Uri;
}
Console.WriteLine($"DelegatingHandler: Sending {request.Method} request to {request.RequestUri}.");
return base.SendAsync(request, cancellationToken);
}
}
internal sealed class FeatureHostedService : IHostedService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptionsMonitor<FeatureOptions> _optionsMonitor;
public FeatureHostedService(IHttpClientFactory httpClientFactory, IOptionsMonitor<FeatureOptions> optionsMonitor)
{
_httpClientFactory = httpClientFactory;
_optionsMonitor = optionsMonitor;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
using (var httpClient = _httpClientFactory.CreateClient("Feature"))
{
var optionsSnapshot = _optionsMonitor.CurrentValue;
httpClient.Timeout = TimeSpan.FromSeconds(optionsSnapshot.TimeoutInSeconds);
Console.WriteLine($"HostedService: Sending request using timeout {httpClient.Timeout}.");
_ = await httpClient.GetAsync("http://steeltoe.io/", cancellationToken);
}
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
To run the sample, create a new Worker Service project with the following appsettings.json contents:
{
"Steeltoe": {
"Feature": {
"ValidateCertificates": false,
"TimeoutInSeconds": 5,
"ForceHttps": false
}
}
}
and replace Program.cs with:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddLogging(options => options.ClearProviders());
builder.Services.AddSteeltoeFeature(builder.Configuration);
var host = builder.Build();
host.Run();
Running this shows the next output:
Creating handler with ValidateCertificates = False.
HostedService: Sending request using timeout 00:00:05.
DelegatingHandler: Sending GET request to http://steeltoe.io/.
HostedService: Sending request using timeout 00:00:05.
DelegatingHandler: Sending GET request to http://steeltoe.io/.
[==> Save appsettings.json with ValidateCertificates=true, TimeoutInSeconds=10, ForceHttps=true]
HostedService: Sending request using timeout 00:00:10.
DelegatingHandler: Sending GET request to https://steeltoe.io/.
HostedService: Sending request using timeout 00:00:10.
DelegatingHandler: Sending GET request to https://steeltoe.io/.
HostedService: Sending request using timeout 00:00:10.
DelegatingHandler: Sending GET request to https://steeltoe.io/.
Creating handler with ValidateCertificates = True.
HostedService: Sending request using timeout 00:00:10.
DelegatingHandler: Sending GET request to https://steeltoe.io/.