Implementing a Round Robin algorithm for an HttpClient


Implementing a Round Robin algorithm for an HttpClient

Implementing a Round Robin algorithm for an HttpClient

The HttpClient, along with the IHttpClientFactory, provides us with a really resilient way of performing HTTP requests. However, it is not a silver bullet. Although it might work in most cases, we might need some adjustments in some cases. For example, if our DNS server provides us with multiple IP Addresses for an endpoint, we might not be able to make the most of it with a standard implementation. So implementing a Round Robin algorithm for an HttpClient in this case can be more beneficial.

What is Round Robin?

Round-Robin is a load-balancing technique. According to Cloudflare, “when the DNS authoritative nameserver is queried for an IP address, the server hands out a different address each time, operating on a rotation.”. For a simple example, let’s suppose that we have an endpoint called bananasformars.com.zy, and a DNS configured to return 2 IP addresses for it, 10.0.0.1 and 10.0.0.2. If we were to make a hundred requests to bananasformars.com.zy, in a flawless situation, 10.0.0.1 would get 50 requests, and 10.0.0.2 the other 50.

Implementing the Round Robin algorithm in C#

In order to get the desired behavior, we need to create a custom SocketsHttpHandler for our HttpClient. We will be a little fancier here and use the HttpClientFactory, which we covered in a previous post. Let’s quickly recap what we built last time:

Program.cs:

using Microsoft.Extensions.DependencyInjection;

var serviceCollection = new ServiceCollection();

serviceCollection.AddHttpClient();
serviceCollection.AddScoped<Requester>();

var provider = serviceCollection.BuildServiceProvider();

var requester = provider.GetRequiredService<Requester>();
await requester.DoRequestAsync();

Requester.cs

class Requester
{
    public HttpClient HttpClient { get; }

    public Requester(HttpClient httpClient)
    {
        HttpClient = httpClient;
    }

    public async Task DoRequestAsync() 
       => await HttpClient.GetAsync("https://www.google.com/");
}

So, basically, in the current solution we are creating a service collection, adding our HttpClient factory, registering our Requester type and doing a simple GET request. Now, for our new solution, we need to create a custom HttpMessageHandler, and since this code can be quite complex, let’s extract it into a factory. The code below is heavily based on Meziantou’s post, but I have attempted to slightly simplify it in order to make it easier to understand.

static class RoundRobinSocketHttpHandlerFactory
{
    public static SocketsHttpHandler Create()
    {
        var sockerHttpHandler = new SocketsHttpHandler()
        {
            // 1
            PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
            PooledConnectionLifetime = TimeSpan.FromSeconds(1),

            // 2
            ConnectCallback = async (context, cancellationToken) =>
            {
                var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, cancellationToken);

                // 3
                IPAddress[] addresses;
                if (entry.AddressList.Length == 1)
                    addresses = entry.AddressList;
                else
                    addresses = IpAddressRouter.Update(entry);

                // 4
                var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);

                try
                {
                    await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
                    return new NetworkStream(socket, ownsSocket: true);
                }
                catch
                {
                    socket.Dispose();
                    throw;
                }
            }
        };

        return sockerHttpHandler;
    }
}

The code above will be executed every time we need a new HttpMessageHandler. In case you are wondering when that happens, here you can find a very detailed explanation about it, in the “What is HttpClientFactory?” section. But basically, we need a HttpMessageHandler every time we create a HttpClient. The HttpClient is kind of a mere shell, whereas most of the connecting and communication logic is handled by the HttpMessageHandler. The HttpMessageHandler will be picked automagically from a pool, and it has a specified lifetime.

With that said, let’s analyze some pieces of this code:

  • 1: Definition of the lifetime of the handler mentioned above. In this case, we are setting it to 1 second, but please do not use this blindly in a production scenario.
  • 2: The logic regarding creating new connections.
  • 3: Here we define our round-robin logic. If we have a single IP address for that hostname, we don’t have to do anything. Otherwise, we will soon analyze our IpAddressRouter class which takes care of that.
  • 4: Finally, we create a new socket and attempt to connect to it.

Let’s then proceed to analyze our IpAddressRouter.cs:

static class IpAddressRouter
{
    private static readonly ConcurrentDictionary<string, int> ipAddresses = new(StringComparer.OrdinalIgnoreCase);

    public static IPAddress[] Update(IPHostEntry entry)
    {
        var index = ipAddresses.AddOrUpdate(entry.HostName, 0, (_, existingValue) => existingValue + 1);

        index %= entry.AddressList.Length;

        IPAddress[] addresses;
        if (index == 0)
            addresses = entry.AddressList;
        else
        {
            addresses = new IPAddress[entry.AddressList.Length];
            entry.AddressList.AsSpan(index).CopyTo(addresses);
            entry.AddressList.AsSpan(0, index).CopyTo(addresses.AsSpan(index));
        }

        return addresses;
    }
}

This code can seem a bit complex if you are not familiar with the ConcurrentDictionary or the Span classes, but otherwise, it is very straightforward. Firstly, we add a new entry to the dictionary for our IPHostEntry (that we got from the Dns.cs class earlier) or update the existing one, and retrieve the updated value. If you aren’t familiar with the ConcurrentDictionary, the AddOrUpdate method we are using has 3 parameters:

  • key: the key for our dictionary, in this case, the hostname (edition.cnn.com)
  • addValue: if we don’t have that key, the value that the key will be added with. In this case, 0.
  • updateValueFactory: if we already have that key, this logic will be applied to update the current value. In this case, we are adding +1 to the existing value.

Moving on, after getting the current index value from the key, we use that to split and organize our list of IP addresses. On the 9th line, we will get the remainder of the division of our index by the number of items in our AddressList. I’m pretty sure that there is a name for this math operation, but it has been long forgotten by my brain. So let’s say that we have only 2 IP Addresses, the value will always be either 0 or 1. If we have 3 IPs, it will be 0, 1, or 3.

If the value turns out to be 0, we will just return the list as it is, otherwise, we will do some sort of shuffling. Let’s suppose our value is 2, and that we have 4 IP addresses: 10.0.0.1, 10.0.0.2, 10.0.0.3 and 10.0.0.4. Our resulting order, in this case, would be 10.0.0.3, 10.0.0.4, 10.0.0.1, 10.0.0.2. That is because, starting from index number 2, we are copying all items from our base IP addresses list to a new list. Then, we are copying all the items from the beginning of the list until index number 2.

Every time we call our IpAddressRouter.Update() method for the same hostname, we will get a different order of IP addresses than the last time. And, since the socket.ConnectAsync() method will prioritize the first entries in the list, in this way we can distribute the load evenly between them.

Finally, let’s get to the creation of the HttpClient. We will modify our registration and use a named client, supposing that we will not want to use our custom SocketsHttpHandler for every request. Our final piece of code should look like this:

Program.cs:

using Microsoft.Extensions.DependencyInjection;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Net;

var serviceCollection = new ServiceCollection();

serviceCollection.AddHttpClient("RoundRobinClient")
    .ConfigurePrimaryHttpMessageHandler(() => RoundRobinSocketHttpHandlerFactory.Create());
serviceCollection.AddScoped<Requester>();

var provider = serviceCollection.BuildServiceProvider();

var requester = provider.GetRequiredService<Requester>();
await requester.DoRequestAsync();

Requester.cs:

class Requester
{
    public HttpClient HttpClient { get; }

    public Requester(IHttpClientFactory httpClientFactory)
    {
        HttpClient = httpClientFactory.CreateClient("RoundRobinClient");
    }

    public async Task DoRequestAsync()
    {
        await HttpClient.GetAsync("https://edition.cnn.com");
    }
}

Considerations

The frequency that we will execute what’s in our ConnectCallback method depends mainly on two things: the frequency that we are making HTTP requests, and the values we provide for PooledConnectionIdleTimeout and PooledConnectionLifetime. If we provide very low values, it means that we will be shuffling our list more constantly. But we have to keep in mind that creating a new HttpMessageHandler can be costly.

Also, by default, .NET will try to reuse connections that were left previously in the pool in order to maximize efficiency. So if we are constantly creating new HttpMessageHandler, we aren’t benefiting from it. But sometimes, it is more desirable to create more connections and shuffle our list more frequently, so we are balancing the load more evenly through our servers. You will have to decide what is more useful to your solution.