Using HttpClient and HttpClientFactory with .NET Core

Connection Pooling

.NET Core is evolving and improving steadily. This has the side effect of content being outdated at a pace faster than ordinary. One such subject matter is HttpClient and connection pooling. Until .NET Core 2.1, there was the possibility of running out of available ports if too many HttpClients were created and disposed in quick succession. Another class was needed, HttpClientFactory, for proper connection pooling. Though we are still better off by using HttpClientFactory instead of creating HttpClient instance everytime you need, it is not necessary for connection pooling anymore.

SocketsHttpHandler is introduced with .NET Core 2.1, to handle HTTP networking... and HttpClient default handler is changed from HttpClientHandler to SocketsHttpHandler... and SocketsHttpHandler does connection pooling by itself. There, problem solved. Steve Gordon has an in depth article on this subject and history of the matter. However HttpClientFactory still has its uses; it not only provides a centralized place and very convenient methods for HttpClient registration in dependency injection configuration, but also named and typed clients registered by HttpClientFactory isolates HttpClient object from rest of the application. HttpClientFactory has detailed documentation and it's beneficial to have a look at, if you are planning to do some Http networking with .NET Core.

Dependency Injection

To use a proxy class encapsulating HttpClient, instead of crating an HttpClient whenever we need one and using its raw methods, we first start by defining proxy class and interface. Code below uses typed client for HttpClientFactory, named client is somewhat different but similar. HttpClientFactory documentation mentioned before describes both methods.


    public interface IHttpNetworking
    {
        Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token);
    }

    public class HttpNetworking : IHttpNetworking
    {
        private readonly HttpClient _httpClient;

        public HttpNetworking(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }

        public Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token)
        {
            //implementation
        }
    }

Then we use HttpClientFactory to register this service in dependency injection. Simplest definition could be done by calling AddHttpClient extension method for IServiceCollection while building generic host. Have a look at configuring generic host builder if you are not familiar with subject.

However while we are at it, let's configure registered service a little further. We can add compression if we are planning to transfer more than trivial amounts of data, also add some fault handling and resiliency to our networking operations.


    return new HostBuilder()
					
    //configure host builder...
    //some code...
					
        .ConfigureServices((hostingContext, services) =>
        {
            //From Microsoft.Extensions.Http package
            services.AddHttpClient<IHttpNetworking, HttpNetworking>(
                    c =>
                    {
                        c.Timeout = TimeSpan.FromSeconds(10);
                        c.BaseAddress = new Uri(//API Address);
                    })
                    //Configure message handler to use compression if available
                    .ConfigurePrimaryHttpMessageHandler(messageHandler =>
                    {
                        var handler = new SocketsHttpHandler() { AutomaticDecompression = DecompressionMethods.Deflate | 
                                DecompressionMethods.Brotli | DecompressionMethods.GZip };
                        return handler;
                    })
                    //From Microsoft.Extensions.Http.Polly package, 
                    //add a circuit breaker for x amount of time if last y attempts failed.
                    .AddTransientHttpErrorPolicy(builder => builder.CircuitBreakerAsync(3, TimeSpan.FromSeconds(300)));
        })
					
        //more code..

Note that you don't need to set BaseAddress while registering HttpClient for dependency injection. Connection Pooling will be handled by SocketsHttpHandler anyway. However Polly CircuitBreaker will not work correctly if same IHttpNetworking instance is used for multiple base addresses at same time, and circuit may be opened for any and all base addresses if operations for one of them fails concurrently. Check "Typed Clients with Multiple Tenants" section for details on how to register and use same interface for multiple base addresses.

Now we can get an instance of our proxy class for HttpClient from dependency injection and call methods defined in implemented interface.


public class SomeClass
{
    private readonly IHttpNetworking _httpNetworking;

    public SomeClass(IHttpNetworking httpNetworking)
    {
        _httpNetworking = httpNetworking;
    }

    public async Task SomeMethod(string queryParameter, CancellationToken token)
    {
        //some code...
        var response = await _httpNetworking.GetInformationWithHttpClient(queryParameter, token);
        //more code...
    }
}

Reading Response from HttpClient

All that's left is to implement HttpClient operations in our proxy class. Suppose we are communicating with an API via Http GET method using JSON formatted messages. A simple way to send request and read response would be;


    public class HttpNetworking : IHttpNetworking
    {
        private readonly HttpClient _httpClient;

        public HttpNetworking(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }
		
        public Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token)
        {
            //address below is request path, not containing base address. base address is set while registering service for dependency injection
            var uri = DetermineRemoteAPIRequestPathForThisOperation();
            var request = new HttpRequestMessage(HttpMethod.Get, uri);
            using (var response = await _httpClient.SendAsync(request, token))
            {
                return await SomeDeserializeJsonStringMethod<ResponseClass>(ProcessResponse(response));
            }
        }
		
        private async Task<string> ProcessResponse(HttpResponseMessage response)
        {
            if (!response.IsSuccessStatusCode)
            {
                throw new ApplicationException($"Web request error. Error code: {response.StatusCode}");
            }
            return await response.Content.ReadAsStringAsync();
        }
    }

However this will not fly if we are receiving a lot of data, because above implementation waits till response is fully received, then reads whole response into a string in memory, then deserializes it into a class. We can do much better, by processing response stream instead of reading all content into string, and by doing it while we are still receiving data instead of waiting transfer to complete. We will utilize HttpCompletionOption.ResponseHeadersRead option and HttpContent.ReadAsStreamAsync method to do so.

Using HttpCompletionOption.ResponseHeadersRead option causes HttpClient to not honor connection timeout or OperationCancelled requests and this may lead to application hangs if data transfer is interrupted. Therefore we are using an additional CancellationTokenSource to track client timeout, link it with input token so timeout token will be cancelled if input token is cancelled and close response stream forcefully on timeout token cancellation to prevent application hang.


    public Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token)
    {
        //address below is request path, not containing base address. base address is set while registering service to dependency injection
        var uri = DetermineRemoteAPIRequestPathForThisOperation();
        var request = new HttpRequestMessage(HttpMethod.Get, uri);

        //Using HttpCompletionOption.ResponseHeadersRead option causes client to
        //not honor connection timeout or OperationCancelled requests.
        //This may lead to application hangs if data transfer is interrupted.
        //Therefore we are using an additional CancellationTokenSource to track client timeout, 
        //link it with input token so timeout token will be cancelled if input token is cancelled 
        //and close response stream forcefully on timeout token cancellation to prevent application hang

        //create a timeout cancellation token source
        using (var timeoutTokenSource = new CancellationTokenSource())
        {
            //longer HttpClient timeout in dependency injection registration can be used
            //if expected data is large and may take long to fully transfer
            timeoutTokenSource.CancelAfter(_httpClient.Timeout);
            var timeoutToken = timeoutTokenSource.Token; //create a timeout token
            //link token to timeout cancellation token source for this scope, 
            //so timeout token will be cancelled if token is cancelled
            using (token.Register(() => timeoutTokenSource.Cancel()))
            {
                using (var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutToken))
                {
                    timeoutToken.ThrowIfCancellationRequested();
                    if (!response.IsSuccessStatusCode)
                    {
                        throw new ApplicationException($"Web request error. Error code: {response.StatusCode}");
                    }
                    using (var contentStream = await response.Content.ReadAsStreamAsync())
                    //close contentStream forcefully if timeout token is cancelled
                    //otherwise operation will hang because of HttpCompletionOption.ResponseHeadersRead
                    using (timeoutToken.Register(() => contentStream.Close()))
                    {
                        timeoutToken.ThrowIfCancellationRequested();
                        return await SomeDeserializeJsonStreamMethod<ResponseClass>(contentStream, token);
                    }
                }
            }
        }
    }

Typed Clients with Multiple Tenants Having Different Base Addresses

Sometimes, you may have a requirement to perform same operations on multiple tenants having different addresses. Fortunately, there is a way to use a single typed client implementation for all of them. You may also choose to use named clients for HttpClientFactory but below code shows using typed client.

Implementation will need to change a little to be able to distinguish one typed client instance from another. This particular code uses a name for distinction, you may use whatever suits your needs.


    public interface IHttpNetworking
    {
        Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token);

        string GetName();
    }

    public class HttpNetworking : IHttpNetworking
    {
        private readonly HttpClient _httpClient;
        private readonly string _clientName;

        public HttpNetworking(HttpClient httpClient, string clientName)
        {
            _httpClient = httpClient;
            _clientName = clientName;
        }

        public string GetName()
        {
            return _clientName;
        }

        public Task<ResponseClass> GetInformationWithHttpClient(string queryParameter, CancellationToken token)
        {
            //implementation
        }
    }

During dependency injection, we will be using a different extension method to further configure typed client instance. Note that you can access service provider along with http client in this overload. Another difference from previous extension method is, configuration implemented is a Func<> in which we return configured typed client instance, not an Action<> returning void.

Add a typed client for each different base address with ConfigureServices method.


    return new HostBuilder()
					
    //configure host builder...
    //some code...
					
        .ConfigureServices((hostingContext, services) =>
        {
            //From Microsoft.Extensions.Http package
            services.AddHttpClient(clientName).AddTypedClient<IHttpNetworking>(
                    (c, s) =>
                    {
                        c.Timeout = TimeSpan.FromSeconds(10);
                        c.BaseAddress = new Uri(//API Address);
                        var client = new HttpNetworking(c, clientName);
                        return client;
                    })
                    //Configure message handler to use compression if available
                    .ConfigurePrimaryHttpMessageHandler(messageHandler =>
                    {
                        var handler = new SocketsHttpHandler() { AutomaticDecompression = DecompressionMethods.Deflate | 
                                DecompressionMethods.Brotli | DecompressionMethods.GZip };
                        return handler;
                    })
                    //From Microsoft.Extensions.Http.Polly package, 
                    //add a circuit breaker for x amount of time if last y attempts failed.
                    .AddTransientHttpErrorPolicy(builder => builder.CircuitBreakerAsync(3, TimeSpan.FromSeconds(300)));
        })
        .ConfigureServices((hostingContext, services) =>
        {
            //From Microsoft.Extensions.Http package
            services.AddHttpClient(anotherClientName).AddTypedClient<IHttpNetworking>(
                    (c, s) =>
                    {
                        c.Timeout = TimeSpan.FromSeconds(10);
                        c.BaseAddress = new Uri(//Another API Address);
                        var client = new HttpNetworking(c, anotherClientName);
                        return client;
                    })
                    //Configure message handler to use compression if available
                    .ConfigurePrimaryHttpMessageHandler(messageHandler =>
                    {
                        var handler = new SocketsHttpHandler() { AutomaticDecompression = DecompressionMethods.Deflate | 
                                DecompressionMethods.Brotli | DecompressionMethods.GZip };
                        return handler;
                    })
                    //From Microsoft.Extensions.Http.Polly package, 
                    //add a circuit breaker for x amount of time if last y attempts failed.
                    .AddTransientHttpErrorPolicy(builder => builder.CircuitBreakerAsync(3, TimeSpan.FromSeconds(300)));
        })
					
        //more code..

While using typed client instance, we now get a collection of typed client instances from dependency injection since we have registered multiple, and have to choose correct one from that collection.


public class SomeClass
{
    private readonly IEnumerable<IHttpNetworking> _httpNetworkingColl;

    public SomeClass(IEnumerable<IHttpNetworking> httpNetworkingColl)
    {
        _httpNetworkingColl = httpNetworkingColl;
    }

    public async Task SomeMethod(string queryParameter, CancellationToken token)
    {
        //some code...
        //Get typed client instance from collection, current implementation uses a name
        var response = await _httpNetworkingColl.First(p=>p.GetName().Equals(clientName, StringComparison.OrdinalIgnoreCase))
            .GetInformationWithHttpClient(queryParameter, token);
        //more code...
    }
}