HTTP Clients and Generating Requests

You,http client

Optimizing HTTP client usage in .NET as part of best practices can be tricky, and if done incorrectly can lead to port exhaustion, excessive API calls, and degraded performance. When tinkering around with calling 3rd party API services. I was building a custom AuthTokenManager (I know, you really shouldn't be putting these in cache for security reasons) and found this article (opens in a new tab).

How do we improve our AuthTokenManager to securely handle API authentication tokens while keeping our system performant? Let’s start with the basics and refactor to follow best practices.

Step 1: Fundamentals - Direct Token Fetching

A 3rd party API I was calling required token retrieval by providing an API key to the endpoint. To facilitate this I created an AuthTokenManager class (Note: this comes with security risks if anyone has access to the local machine - take this as a learning excercise).

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
 
class AuthTokenManager
{
    public async Task<string> GetToken()
    {
        using var client = new HttpClient(); // Creating a new instance every call ❌
 
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "scope", "SERVER_ACCESS" }
        });
 
        client.DefaultRequestHeaders.Add("Authorization", $"Basic {Environment.GetEnvironmentVariable("BASIQ_API_KEY")}");
        client.DefaultRequestHeaders.Add("basiq-version", "3.0");
 
        var response = await client.PostAsync("https://au-api.basiq.io/token", content);
        var jsonResponse = await response.Content.ReadAsStringAsync();
        var tokenData = JsonSerializer.Deserialize<TokenResponse>(jsonResponse);
        
        return tokenData?.AccessToken;
    }
}
 
class TokenResponse
{
    public string AccessToken { get; set; }
}

There's a lot wrong with this implementation - let's unpack them:

  1. A new HttpClient is instantiated after time we get a token, which can lead to port exhaustion. What is port exhaustion? Essentially when we close a TCP connection the port doesn't get released immediately and if the rate of requests is extremely high, the operating system limit of (ephemeral) ports becomes exhausted. More information can be found here (opens in a new tab). This renders the server unable to allow new connections.
  2. Another issue is that we aren't caching the token; even though this may be a security issue, the intention is to avoid requesting a new token every time if the current one is valid.
  3. There is no error handling, which means failures from 3rd party APIs can have unpredictable downstream effects.

Step 2: Token Caching

Let's update the AuthTokenManager to store the in-memory token and prevent unnecessary API calls. From the 3rd party API docs:

Note: Your token will expire after 60 minutes, so ensure you are caching the token and only generating a new one when necessary. Excessive use of the/token endpoint may result in unsuccessful requests.

class AuthTokenManager
{
    private static string _cachedToken;
    private static DateTime _expiryTime;
 
    public async Task<string> GetToken()
    {
        if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _expiryTime)
            return _cachedToken;
 
        _cachedToken = await RequestNewToken();
        _expiryTime = DateTime.UtcNow.AddMinutes(60); // Adjust expiry based on API response
 
        return _cachedToken;
    }
 
    private async Task<string> RequestNewToken()
    {
        using var client = new HttpClient(); // Still creates a new instance ❌
 
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "scope", "SERVER_ACCESS" }
        });
 
        client.DefaultRequestHeaders.Add("Authorization", $"Basic {Environment.GetEnvironmentVariable("BASIQ_API_KEY")}");
        client.DefaultRequestHeaders.Add("basiq-version", "3.0");
 
        var response = await client.PostAsync("https://au-api.basiq.io/token", content);
        response.EnsureSuccessStatusCode(); // Added error handling ✅
 
        var jsonResponse = await response.Content.ReadAsStringAsync();
        var tokenData = JsonSerializer.Deserialize<TokenResponse>(jsonResponse);
        
        return tokenData?.AccessToken;
    }
}

We've solved 2. and 3., let's tackle how to property manage HTTP clients.

Step 3. Using IHttpClientFactory

Instead of creating new instances, we'll use the IHttpClientFactory which pools connections and prevents port exhaustion.

using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
 
class AuthTokenManager
{
    private readonly IHttpClientFactory _httpClientFactory;
    private static string _cachedToken;
    private static DateTime _expiryTime;
 
    public AuthTokenManager(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }
 
    public async Task<string> GetToken()
    {
        if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _expiryTime)
            return _cachedToken;
 
        _cachedToken = await RequestNewToken();
        _expiryTime = DateTime.UtcNow.AddMinutes(60);
        return _cachedToken;
    }
 
    private async Task<string> RequestNewToken()
    {
        using var client = _httpClientFactory.CreateClient("BasiqApi"); // Uses managed connection ✅
 
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "scope", "SERVER_ACCESS" }
        });
 
        client.DefaultRequestHeaders.Add("Authorization", $"Basic {Environment.GetEnvironmentVariable("BASIQ_API_KEY")}");
        client.DefaultRequestHeaders.Add("basiq-version", "3.0");
 
        var response = await client.PostAsync("https://au-api.basiq.io/token", content);
        response.EnsureSuccessStatusCode();
 
        var jsonResponse = await response.Content.ReadAsStringAsync();
        var tokenData = JsonSerializer.Deserialize<TokenResponse>(jsonResponse);
        
        return tokenData?.AccessToken;
    }
}
 

Note that the IHttpClientFactory needs to be registered - I did this in Program.cs.

To summarise, if you're making a new HttpClient for every request you're basically DDoSing yourself.

© Jason Song.