You Use the HttpClient Wrong

Introduction

This article explains how to properly send HTTP requests in C#.

It emphasizes the drawbacks of using the HttpClient directly, as well as the advantages of using the IHttpClientFactory.

Also, it shows 3 ways to use the IHttpClientFactory in your application.

The Naive Approach

The most intuitive way to send HTTP requests in C# is by directly using the built-in C# HttpClient class.

Below there's an easy example of how to send HTTP requests by using the naive approach.

string url = "https://cat-fact.herokuapp.com/facts";

using var client = new HttpClient();

var msg = new HttpRequestMessage(HttpMethod.Get, url);

var res = await client.SendAsync(msg);

var content = await res.Content.ReadAsStringAsync();

Console.WriteLine("Result={0}", content);

The issue with generating a HttpClient for each request is that there is an overhead of instantiation, and on top of that, each HttpClient will keep the socket that it utilized open for some time after the request is completed.

If you have made a large number of requests, you may experience socket exhaustion (when a new HttpClient cannot obtain a socket to make a request).

When we use the HttpClient object within a using code block, the HttpClient object is disposed after usage (since HttpClient implements IDisposable), but the socket connection is not, which can lead to a socket exhaustion problem when your traffic increases.

A better way is to use a static HttpClient or a singleton, such that the same object is used for every request.

Even in this particular case, you must pay special attention to DNS TTL by configuring the PooledConnectionLifetime value.

The DNS TTL option instructs the DNS resolver how long to cache a query before requesting a fresh one.

By specifying a value for this, you will avoid situations such as the domain name pointing to a new IP address and the HttpClient no longer being able to connect to the server.

Below is an example of a helper class that makes an HTTP request using a static HttpClient.

public static class HttpClientHelper
{
    private static HttpClient _httpClient = new HttpClient(new SocketHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(1)
    });


    public static async Task<IEnumerable<Fact>> GetFactsAsync()
    {
        try
        {
            var response = await _httpClient.GetAsync("https://cat-fact.herokuapp.com/facts");
            var facts = JsonSerializer.Deserialize<IEnumerable<Fact>>(await response.Content.ReadAsStringAsync());
            return facts;
        }
        catch(Exception ex)
        {
            return await Task.FromException<IEnumerable<Fact>>(ex);
        }
    }
}

A better way to make HTTP requests

IHttpClientFactory was introduced in .NET Core 2.1 (also available in.NET 5+) and provides a significantly improved mechanism for obtaining a HTTP client.

The IHttpClientFactory solves both of the previously mentioned problems in the naive approach, by pooling the HttpClientHandler, which fixes the socket exhaustion issue, and by disposing of HttpClientHandlers, which ensures that the HttpClient is informed about the most recent mappings between domain names and IP addresses.

There are 3 ways to integrate IHttpClientFactory in your ASP.NET Core application:

  • Use IHttpClientFactory directly

  • Use named clients

  • Use typed clients

Direct use of IHttpClientFactory

This is arguably the easiest way to use IHttpClientFactory in your ASP.NET Core application.

We could use ASP.NET Core dependency injection support for providing an instance of http client factory in our controller.

After that, we will use the same http client factory to create a HttpClient instance where it's necessary to call an external API.

// Program.cs
// Configure necessary services
builder.Services.AddHttpClient();
// FactsController.cs
[ApiController]
public class FactsController : Controller
{
    private readonly IHttpClientFactory _httpClientFactory;

    public FactsController(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<Fact>>> GetFactsAsync()
    {
        var client = _httpClientFactory.CreateClient();
        var response = await client.GetAsync("https://cat-fact.herokuapp.com/facts");
        var facts = JsonSerializer.Deserialize<IEnumerable<Fact>>(await response.Content.ReadAsStringAsync());
        return Ok(facts);
    }
}

Named clients for IHttpClientFactory

If you need to have many distinct uses of HttpClient or if you need to have many clients with different configurations, you should consider using named clients.

By using named clients, you could configure HttpClient options such as API endpoint URL, or headers when you set up application services, and the same options will be shared for all the clients that will be further created.

An example of using named clients to access the facts API is shown below.

// Program.cs
builder.Services.AddHttpClient("FactsClient", client =>
{
    client.BaseAddress = new Uri("https://cat-fact.herokuapp.com");
})
// FactsController.cs
[ApiController]
public class FactsController : Controller
{
    private readonly IHttpClientFactory _httpClientFactory;

    public FactsController(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<Fact>>> GetFactsAsync()
    {
        var client = _httpClientFactory.CreateClient("FactsClient");
        var response = await client.GetAsync("/facts");
        var facts = JsonSerializer.Deserialize<IEnumerable<Fact>>(await response.Content.ReadAsStringAsync());
        return Ok(facts);
    }
}

Typed clients for IHttpClientFactory

Typed clients are similar to named clients, except instead of utilizing a string as a key, we use strong types.

A typed client is a great way to encapsulate all of the logic, therefore keeping the configuration step of http clients cleaner and easier to read and maintain.

Here is the list of the main features of typed clients:

  • There is no longer any need to deal with strings, as in the named client.

  • Logic for HTTP calls is encapsulated.

  • In comparison to named customers, this is a more flexible method.

  • The code is simple to test.

An example of using typed clients to access the facts API is shown below.

// Program.cs
builder.Services.AddHttpClient<IFactsService, FactsService>();
// FactsService.cs
public class FactsService : IFactsService
{
    private readonly HttpClient _httpClient;

    public FactsService(HttpClient httpClient)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://cat-fact.herokuapp.com");
    }

    public async Task<IEnumerable<Fact>> GetFactsAsync()
    {
        var response = await client.GetAsync("/facts");
        var facts = JsonSerializer.Deserialize<IEnumerable<Fact>>(await response.Content.ReadAsStringAsync());
        return facts;
    }
}
// FactsController
[ApiController]
public class FactsController : Controller
{
    private readonly IFactsService _factsService;

    public FactsController(IFactsService factsService)
    {
        _factsService = factsService;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<Fact>>> GetFactsAsync()
    {
        return Ok(await _factsService.GetFactsAsync());
    }
}

Conclusion

To avoid socket exhaustion and stale DNS issues, you should always use IHttpClientFactory instead of the naive approach when making API calls.

There are 3 ways to use IHttpClientFactory in your application, but I prefer the third one: type clients for its flexibility, testability, and encapsulating of HTTP calls logic.

In conclusion, embracing the power of IHttpClientFactory and adopting type clients as your preferred method for making HTTP requests is a best practice that will not only help you avoid common pitfalls but also lead to more robust, efficient, and maintainable code.