Update (04 April 2021)
Updated all code for .NET 5 and mentioned Open Telemetry.
Update (20 August 2018)
Steve Gordon kindly suggested a further optimisation to use ConfigureHttpClient
. I've updated the code below to reflect this.
In this post, I'm going to show how to optimally configure a HttpClient
using the new HttpClientFactory API in ASP.NET Core 2.1. If you haven't already I recommend reading Steve Gordon's series of blog posts on the subject since this post builds on that knowledge. You should also read his post about Correlation ID's as I'm making use of that library in this post. The main aims of the code in this post are to:
- Use the
HttpClientFactory
typed client, I don't know why the ASP.NET team bothered to provide three ways to register a client, the typed client is the one to use. It provides type safety and removes the need for magic strings. - Enable GZIP decompression of responses for better performance. Interestingly, the
HttpClient
and ASP.NET Core does not support compression of GZIP requests, only responses. Doing some searching online some time ago suggests that this is an optimisation that is not very common at all, I thought this was pretty unbelievable at the time. - The
HttpClient
should time out after the server does not respond after a set amount of time. - The
HttpClient
should retry requests which fail due to transient errors. - The
HttpClient
should stop performing new requests for a period of time when a consecutive number of requests fail using the circuit breaker pattern. Failing fast in this way helps to protect an API or database that may be under high load and means the client gets a failed response quickly rather than waiting for a time-out. - The URL, time-out, retry and circuit breaker settings should be configurable from the
appsettings.json
file. - The
HttpClient
should send aUser-Agent
HTTP header telling the server the name and version of the calling application. If the server is logging this information, this can be useful for debugging purposes. - The
X-Correlation-ID
HTTP header from the response should be passed on to the request made using theHttpClient
. This would make it easy to correlate a request across multiple applications.
Usage Example
It doesn't really matter what the typed client HttpClient
looks like, that's not what we're talking about but I include it for context.
public interface IRocketClient
{
Task<TakeoffStatus> GetStatus(bool working);
}
public class RocketClient : IRocketClient
{
private readonly HttpClient httpClient;
public RocketClient(HttpClient httpClient) => this.httpClient = httpClient;
public async Task<TakeoffStatus> GetStatus(bool working)
{
var response = await this.httpClient.GetAsync(working ? "status-working" : "status-failing");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TakeoffStatus>();
}
}
Here is how we register the typed client above with our dependency injection container. All of the meat lives in these three methods. AddCorrelationId
adds a middleware written by Steve Gordon to handle Correlation ID's. AddPolicies
registers a policy registry and the policies themselves (A policy is Polly's way of specifying how you want to deal with errors e.g. using retries, circuit breaker pattern etc.). Finally, we add the typed HttpClient
but with configuration options, so we can configure it's settings from appsettings.json
.
public virtual void ConfigureServices(IServiceCollection services) =>
services
.AddDefaultCorrelationId() // Add Correlation ID support to ASP.NET Core
.AddControllers()
.Services
.AddPolicies(this.configuration) // Setup Polly policies.
.AddHttpClient<IRocketClient, RocketClient, RocketClientOptions>(this.configuration, "RocketClient")
...;
The appsettings.json
file below contains the base address for the endpoint we want to connect to, a time-out value of thirty seconds is used if the server is taking too long to respond and policy settings for retries and the circuit breaker.
The retry settings state that after a first failed request, another three attempts will be made (this means you can get up to four requests). There will be an exponentially longer back-off or delay between each request. The first retry request will occur after two seconds, the second after another four seconds and the third occurs after another eight seconds.
The circuit breaker states that it will allow 12 consecutive failed requests before breaking the circuit and throwing CircuitBrokenException
for every attempted request. The circuit will be broken for thirty seconds.
Generally, my advice is when allowing a high number of exceptions before breaking, use a longer duration of break. When allowing a lower number of exceptions before breaking, keep the duration of break small.
Another possibility I've not tried is to combine these two scenarios, so you have two circuit breakers. The circuit breaker with the lower limit would kick in first but only break the circuit for a short time, if exceptions are no longer thrown, then things go back to normal quickly. If exceptions continue to be thrown, then the other circuit breaker with a longer duration of break would kick in and the circuit would be broken for a longer period of time. I leave implementing this particular scenario to the reader.
You can of course play with these numbers, what you set them to will depend on your application.
{
"RocketClient": {
"BaseAddress": "http://example.com",
"Timeout": "00:00:30"
},
"Policies": {
"HttpCircuitBreaker": {
"DurationOfBreak": "00:00:30",
"ExceptionsAllowedBeforeBreaking": 12
},
"HttpRetry": {
"BackoffPower": 2,
"Count": 3
}
}
}
Configuring Polly Policies
Below is the implementation for AddPollyPolicies
. It starts by setting up and reading a configuration section in our appsettings.json
file of type PolicyOptions
. Then adds the PolicyRegistry which is where Polly stores it's policies. Finally, we add a retry and circuit breaker policy and configure them using the settings we've read from the PolicyOptions
.
public static class ServiceCollectionExtensions
{
private const string PoliciesConfigurationSectionName = "Policies";
public static IServiceCollection AddPolicies(
this IServiceCollection services,
IConfiguration configuration,
string configurationSectionName = PoliciesConfigurationSectionName)
{
services.Configure<PolicyOptions>(configuration);
var policyOptions = configuration.GetSection(configurationSectionName).Get<PolicyOptions>();
var policyRegistry = services.AddPolicyRegistry();
policyRegistry.Add(
PolicyName.HttpRetry,
HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(
policyOptions.HttpRetry.Count,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(policyOptions.HttpRetry.BackoffPower, retryAttempt))));
policyRegistry.Add(
PolicyName.HttpCircuitBreaker,
HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: policyOptions.HttpCircuitBreaker.ExceptionsAllowedBeforeBreaking,
durationOfBreak: policyOptions.HttpCircuitBreaker.DurationOfBreak));
return services;
}
}
public static class PolicyName
{
public const string HttpCircuitBreaker = nameof(HttpCircuitBreaker);
public const string HttpRetry = nameof(HttpRetry);
}
public class PolicyOptions
{
public CircuitBreakerPolicyOptions HttpCircuitBreaker { get; set; }
public RetryPolicyOptions HttpRetry { get; set; }
}
public class CircuitBreakerPolicyOptions
{
public TimeSpan DurationOfBreak { get; set; } = TimeSpan.FromSeconds(30);
public int ExceptionsAllowedBeforeBreaking { get; set; } = 12;
}
public class RetryPolicyOptions
{
public int Count { get; set; } = 3;
public int BackoffPower { get; set; } = 2;
}
Notice that each policy is using the HandleTransientHttpError
method which tells Polly when to apply the retry and circuit breakers. One important question is, what is a transient HTTP error according to Polly? Well, looking at the source code in the Polly.Extensions.Http
GitHub repository, it looks like they consider any of the below as transient errors:
- Any
HttpRequestException
thrown. This can happen when the server is down. - A response with a status code of
408 Request Timeout
. - A response with a status code of
500 Internal Server Error
or above.
Configuring HttpClient
Finally, we can get down to configuring our HttpClient
itself. The AddHttpClient
method starts by binding the TClientOptions
type to a configuration section in appsettings.json
. TClientOptions
is a derived type of HttpClientOptions
which just contains a base address and time-out value. I'll come back to CorrelationIdDelegatingHandler
and UserAgentDelegatingHandler
in a moment.
We set the HttpClientHandler
to be DefaultHttpClientHandler
. This type just enables Brotli, GZIP and Deflate compression. Finally, we add the retry and circuit breaker policies to the HttpClient
.
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddHttpClient<TClient, TImplementation, TClientOptions>(
this IServiceCollection services,
IConfiguration configuration,
string configurationSectionName)
where TClient : class
where TImplementation : class, TClient
where TClientOptions : HttpClientOptions, new() =>
services
.Configure<TClientOptions>(configuration.GetSection(configurationSectionName))
.AddSingleton<CorrelationIdDelegatingHandler>()
.AddSingleton<UserAgentDelegatingHandler>()
.AddHttpClient<TClient, TImplementation>()
.ConfigureHttpClient(
(serviceProvider, httpClient) =>
{
var httpClientOptions = serviceProvider
.GetRequiredService<IOptions<TClientOptions>>()
.Value;
httpClient.BaseAddress = httpClientOptions.BaseAddress;
httpClient.Timeout = httpClientOptions.Timeout;
})
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler())
.AddPolicyHandlerFromRegistry(PolicyName.HttpRetry)
.AddPolicyHandlerFromRegistry(PolicyName.HttpCircuitBreaker)
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>()
.AddHttpMessageHandler<UserAgentDelegatingHandler>()
.Services;
}
public class DefaultHttpClientHandler : HttpClientHandler
{
public DefaultHttpClientHandler() => this.AutomaticDecompression =
DecompressionMethods.Brotli |
DecompressionMethods.Deflate |
DecompressionMethods.GZip;
}
public class HttpClientOptions
{
public Uri BaseAddress { get; set; }
public TimeSpan Timeout { get; set; }
}
CorrelationIdDelegatingHandler
When I'm making a HTTP request from an API i.e. it's an API to API call and I control both sides, I use the X-Correlation-ID
HTTP header to trace requests as they move down the stack. The CorrelationIdDelegatingHandler
is used to take the correlation ID for the current HTTP request and pass it down to the request made in the API to API call. The implementation is pretty simple, it's just setting a HTTP header.
The power comes when you are using something like Application Insights, Kibana or Seq for logging. You can now take the correlation ID for a request and see the logs for it from multiple API's or services. This is really invaluable when you are dealing with a micro services architecture.
public class CorrelationIdDelegatingHandler : DelegatingHandler
{
private readonly ICorrelationContextAccessor correlationContextAccessor;
private readonly IOptions<CorrelationIdOptions> options;
public CorrelationIdDelegatingHandler(
ICorrelationContextAccessor correlationContextAccessor,
IOptions<CorrelationIdOptions> options)
{
this.correlationContextAccessor = correlationContextAccessor;
this.options = options;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!request.Headers.Contains(this.options.Value.RequestHeader))
{
request.Headers.Add(this.options.Value.RequestHeader, this.correlationContextAccessor.CorrelationContext.CorrelationId);
}
// Else the header has already been added due to a retry.
return base.SendAsync(request, cancellationToken);
}
}
UserAgentDelegatingHandler
It's often useful to know something about the client that is calling your API for logging and debugging purposes. You can use the User-Agent
HTTP header for this purpose.
The UserAgentDelegatingHandler
just sets the User-Agent
HTTP header by taking the API's assembly name and version attributes. You need to set the Version
and Product
attributes in your csproj
file for this to work. The name and version are then placed along with the current operating system into the User-Agent string.
Now the next time you get an error in your API, you'll know the client application that caused it (if it's under your control).
public class UserAgentDelegatingHandler : DelegatingHandler
{
public UserAgentDelegatingHandler()
: this(Assembly.GetEntryAssembly())
{
}
public UserAgentDelegatingHandler(Assembly assembly)
: this(GetProduct(assembly), GetVersion(assembly))
{
}
public UserAgentDelegatingHandler(string applicationName, string applicationVersion)
{
if (applicationName == null)
{
throw new ArgumentNullException(nameof(applicationName));
}
if (applicationVersion == null)
{
throw new ArgumentNullException(nameof(applicationVersion));
}
this.UserAgentValues = new List<ProductInfoHeaderValue>()
{
new ProductInfoHeaderValue(applicationName.Replace(' ', '-'), applicationVersion),
new ProductInfoHeaderValue($"({Environment.OSVersion})"),
};
}
public UserAgentDelegatingHandler(List<ProductInfoHeaderValue> userAgentValues) =>
this.UserAgentValues = userAgentValues ?? throw new ArgumentNullException(nameof(userAgentValues));
public List<ProductInfoHeaderValue> UserAgentValues { get; set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!request.Headers.UserAgent.Any())
{
foreach (var userAgentValue in this.UserAgentValues)
{
request.Headers.UserAgent.Add(userAgentValue);
}
}
// Else the header has already been added due to a retry.
return base.SendAsync(request, cancellationToken);
}
private static string GetProduct(Assembly assembly) =>
assembly.GetCustomAttribute<AssemblyProductAttribute>().Product;
private static string GetVersion(Assembly assembly) =>
assembly.GetCustomAttribute<AssemblyFileVersionAttribute>().Version;
}
<PropertyGroup Label="Package">
<Version>1.0.0</Version>
<Product>My Application</Product>
<!-- ... -->
</PropertyGroup>
Open Telemetry
Setting the X-Correlation-ID
and User-Agent
HTTP headers are useful things to do but there is a new set of HTTP headers which come under the Open Telemetry standard which not only replace them but also add additional functionality. You can read more about Open Telemetry in my series of blog posts on the subject.
Sample GitHub Project
I realize that was a lot of boilerplate code to write. It was difficult to write this as more than one blog post. To aid in digestion, I've created a GitHub sample project with the full working code.
The sample project contains two API's. One makes a HTTP request to the other. You can pass a query argument to decide whether the callee API will fail or not and try out the retry and circuit breaker logic. Feel free to play with the configuration in appsettings.json
and see what options work best for your application.
Comment
Initializing...