Thursday, November 2, 2023

Comparison of HTTP libraries

In .NET applications, we often need to make HTTP calls. In these cases, we can use the standard HttpClient class or some other library. For example, I have already used Refit and RestSharp. But I have never decided which one to use. Always the library was already utilized in the project I was working with. Therefore, I decided to compare these libraries to form my own meaningful opinion, which one is better and why. This is what I will do in this article.

But how should I compare these libraries? I have no doubt that they all can send HTTP requests and receive responses. After all, these libraries wouldn't have become so popular if they couldn't do that. Therefore, I'm more interested in additional features that are in demand in large corporative applications.

Ok, let's start.

Initial setup

As a service to communicate with we'll use a simple Web API:

    
[ApiController]
[Route("[controller]")]
public class DataController : ControllerBase
{
    [HttpGet("hello")]
    public IActionResult GetHello()
    {
        return Ok("Hello");
    }
}
    

Now let's create clients for this service using our 3 libraries.

We'll create an interface:

    
public interface IServiceClient
{
    Task<string> GetHello();
}
    

Its implementation using HttpClient looks like this:

    
public class ServiceClient : IServiceClient
{
    private readonly HttpClient _httpClient;

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

    public async Task<string> GetHello()
    {
        var response = await _httpClient.GetAsync("http://localhost:5001/data/hello");

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}
    

Now we must prepare the dependency container:

    
var services = new ServiceCollection();

services.AddHttpClient<IServiceClient, ServiceClient>();
    

In the case of RestSharp, the implementation has the following form:

    
public class ServiceClient : IServiceClient
{
    public async Task<string?> GetHello()
    {
        var client = new RestClient();

        var request = new RestRequest("http://localhost:5001/data/hello");

        return await client.GetAsync<string>(request);
    }
}
    

The dependency container should be prepared as follows:

    
var services = new ServiceCollection();

services.AddTransient<IServiceClient, ServiceClient>();
    

And for Refit we have to define a separate interface:

    
public interface IServiceClient
{
    [Get("/data/hello")]
    Task<string> GetHello();
}
    

Its registration is as follows:

    
var services = new ServiceCollection();

services
    .AddRefitClient<IServiceClient>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("http://localhost:5001");
    });
    

After that, there are no problems with using of these clients.

Performance comparison

First of all, let's compare performance of these libraries. We'll measure simple GET-request using Benchmark.Net. Here are the results:

Method Mean Error StdDev Min Max
HttpClient 187.1 us 4.31 us 12.72 us 127.0 us 211.8 us
Refit 207.3 us 4.47 us 13.12 us 138.4 us 226.7 us
RestSharp 724.5 us 14.36 us 36.03 us 657.6 us 902.7 us

It is obvious, that RestSharp takes much longer to execute the request. Let's understand why.

Here is our code for the RestSharp client:

    
public async Task<string?> GetHello()
{
    var client = new RestClient();

    var request = new RestRequest("http://localhost:5001/data/hello");

    return await client.GetAsync<string>(request);
}
    

As you see, we create a new RestClient object for each request. Inside, it creates and initializes a new HttpClient instance. That's what time is spent on. But RestSharp allows us to use a ready-made instance of HttpClient. Let's slightly change the code of our client:

    
public class ServiceClient : IServiceClient
{
    private readonly HttpClient _httpClient;

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

    public async Task<string?> GetHello()
    {
        var client = new RestClient(_httpClient);

        var request = new RestRequest("http://localhost:5001/data/hello");

        return await client.GetAsync<string>(request);
    }
}
    

And initialization should also be changed:

    
var services = new ServiceCollection();

services.AddHttpClient<IServiceClient, ServiceClient>();
    

Now the performance comparison results look more uniform:

Method Mean Error StdDev Median Min Max
HttpClient 190.2 us 3.79 us 10.61 us 190.8 us 163.1 us 214.5 us
Refit 180.8 us 12.20 us 35.96 us 205.2 us 122.5 us 229.3 us
RestSharp 242.8 us 7.45 us 21.73 us 248.5 us 160.4 us 278.5 us

Base address

Sometimes we need to change the base address for requests during the execution of our application. For example, our system works with several MT4 trading servers. During the operation of our application, you can connect and disconnect trading servers. Since all these trading servers have the same API, we can use one client to communicate with them. But they have different base addresses. And these addresses are unknown at the start of our system.

For HttpClient and RestSharp, this is not a problem. Here is the code for HttpClient:

    
public async Task<string> GetHelloFrom(string baseAddress)
{
    var response = await _httpClient.GetAsync($"{baseAddress.TrimEnd('/')}/data/hello");

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadAsStringAsync();
}
    

and here is one for RestSharp:

    
public async Task<string?> GetHelloFrom(string baseAddress)
{
    var client = new RestClient(_httpClient);

    var request = new RestRequest($"{baseAddress.TrimEnd('/')}/data/hello");

    return await client.GetAsync<string>(request);
}
    

But for Refit, it is slightly more complicated. We specified the base address at the configuration stage:

    
services
    .AddRefitClient<IServiceClient>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("http://localhost:5001");
    });
    

But now we can't do that. We only have an interface, but not its implementation. Fortunately, Refit allows us to create an instances of this interface manually by specifying base address. To do this, we'll create a factory for our interfaces:

    
internal class RefitClientFactory
{
    public T GetClientFor<T>(string baseUrl)
    {
        RefitSettings settings = new RefitSettings();

        return RestService.For<T>(baseUrl, settings);
    }
}
    

Let's register it in our dependency container:

    
services.AddScoped<RefitClientFactory>();
    

We'll use this factory every time we want to explicitly set the base address:

    
var factory = provider.GetRequiredService<RefitClientFactory>();

var client = factory.GetClientFor<IServiceClient>("http://localhost:5001");

var response = await client.GetHello();
    

Common processing of requests

We can divide into two groups all the actions we perform during HTTP requests. The first group contains actions that depend on a specific endpoint. Fjr example, during calls to the ServiceA we need to apply one actions, and other actions during calls to ServiceB. In this case, we simply perform these actions inside the implementation of the client interfaces for these services: IServiceAClient and IServiceBClient. There are no problems with this approach in case of using HttpClient and RestSharp. But in case of Refit, we do not actually have a client interface implementation. In this situation, we can use an ordinary decorator (for example, from Scrutor library).

The second group contains actions that must be performed for each HTTP request regardless of the endpoint. These are actions such as error logging, request time measurement, etc. Although we can also implement this logic inside the implementations of our client interfaces, I don't like this approach. There are too many things to do, too many places to change, and it is easy to forget something if a new client is created. Can we define some code that will be executed on every request?

Yes, we can. We can add our own handler to the chain of standard request handlers. Consider the following example. Let's say we want to log information about requests. In this case, we may create a class inheriting DelegatingHandler:

    
public class LoggingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        try
        {
            AnsiConsole.MarkupLine($"[yellow]Sending {request.Method} request to {request.RequestUri}[/]");

            return await base.SendAsync(request, cancellationToken);
        }
        catch (Exception ex)
        {
            AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is failed: {ex.Message}[/]");
            throw;
        }
        finally
        {
            AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is finished[/]");
        }
    }

    protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        try
        {
            AnsiConsole.MarkupLine($"[yellow]Sending {request.Method} request to {request.RequestUri}[/]");

            return base.Send(request, cancellationToken);
        }
        catch (Exception ex)
        {
            AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is failed: {ex.Message}[/]");
            throw;
        }
        finally
        {
            AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is finished[/]");
        }
    }
}
    

It is easy to add this class into the chain of request handlers:

    
services.AddTransient<LoggingHandler>();
services.ConfigureAll<HttpClientFactoryOptions>(options =>
{
    options.HttpMessageHandlerBuilderActions.Add(builder =>
    {
        builder.AdditionalHandlers.Add(builder.Services.GetRequiredService<LoggingHandler>());
    });
});
    

After that, our logging will be performed for each request via HttpClient. The same approach works fine with RestSharp, since we use it as a wrapper around HttpClient.

With Refit everything is a little more complicated. This approach works fine with Refit until we try to use our factory to replace the base address. It looks like the call of RestService.For does not use the settings of HttpClient. That's why we'll have to manually add our request handler:

    
internal class RefitClientFactory
{
    public T GetClientFor<T>(string baseUrl)
    {
        RefitSettings settings = new RefitSettings();
        settings.HttpMessageHandlerFactory = () => new LoggingHandler
        {
            InnerHandler = new HttpClientHandler()
        };

        return RestService.For<T>(baseUrl, settings);
    }
}
    

Request cancellation

Sometimes we need to cancel the request. For example, a user got tired of waiting for a response from the server and left some UI page. Now results of the request are no longer needed and we should cancel the request. How can we do it?

ASP.NET Core allows us to understand that client has cancelled the request with the help of CancellationToken class. Naturally, it would be useful if our libraries supported this class.

With HttpClient it works fine:

    
public async Task<string> GetLong(CancellationToken cancellationToken)
{
    var response = await _httpClient.GetAsync("http://localhost:5001/data/long", cancellationToken);

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadAsStringAsync();
}
    

Here we have CancellationToken support out of the box. The same situation is with RestSharp:

    
public async Task<string?> GetLong(CancellationToken cancellationToken)
{
    var client = new RestClient(_httpClient);

    var request = new RestRequest("http://localhost:5001/data/long") {  };

    return await client.GetAsync<string>(request, cancellationToken);
}
    

Refit also supports CancellationToken:

    
public interface IServiceClient
{
    [Get("/data/long")]
    Task<string> GetLong(CancellationToken cancellationToken);

    ...
}
    

As you can see, there are no problems with request cancellation.

Request timeout

In addition to being able to cancel requests, it would be nice to be able to limit the duration of the request. Here situation is opposite to the case of the common processing logic. It is easy to set up common request timeout for any request in the configuration. But it is useful to be able to specify this timeout for each specific request. Indeed, even on the same server, different endpoints process different amount of information. And this leads to different request processing times. That's why it is better to be able to set different timeouts for different endpoints.

RestSharp has no problem with that:

    
public async Task<string?> GetLongWithTimeout(TimeSpan timeout, CancellationToken cancellationToken = default)
{
    try
    {
        var client = new RestClient(_httpClient, new RestClientOptions { MaxTimeout = (int)timeout.TotalMilliseconds });

        var request = new RestRequest("http://localhost:5001/data/long");

        return await client.GetAsync<string>(request, cancellationToken);
    }
    catch (TimeoutException)
    {
        return "Timeout";
    }
}
    

With HttpClient we already have some problems. On the one hand, HttpClient has the Timeout property that can be used. But here I have some doubts. First of all, the same instance of HttpClient is used in different methods of the class implementing our HTTP client interface. In each method, the timeout expectations can be different. It is easy to forget something, and the timeout from one method will leak to another method. This problem can be overcome with the help of a wrapper that will set timeout at the beginning of each method and return it back to its original value at the end. If the client is not used in multithreading mode, this approach will work.

But, in addition, I have some uncertainty about using different instances of HttpClient class from dependency container. According to the documentation, it is a bad idea to create new instance of HttpClient class every time we need to send an HTTP request. The system internally supports a reusable pool of connections, checks various conditions, etc. In other words, there is a lot of magic. That's why I'm afraid it is possible that the same instance of HttpClient class can be used by different services. And the timeout set in one of them can leak into another one. I must say that I have not been able to reproduce this situation, but maybe I just don't understand something.

In short, I want to be sure that my request timeout will be used only for one specific request and nowhere else. And this can be done using the same CancellationToken:

    
public async Task<string> GetLongWithTimeout(TimeSpan timeout, CancellationToken cancellationToken = default)
{
    try
    {
        using var tokenSource = new CancellationTokenSource(timeout);

        using var registration = cancellationToken.Register(tokenSource.Cancel);

        var response = await _httpClient.GetAsync("http://localhost:5001/data/long", tokenSource.Token);

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
    catch (TaskCanceledException)
    {
        return "Timeout";
    }
}
    

The same method can be applied to Refit:

    
var client = provider.GetRequiredService<IServiceClient>();

using var cancellationTokenSource = new CancellationTokenSource();

try
{
    var response = await Helper.WithTimeout(
        TimeSpan.FromSeconds(5),
        cancellationTokenSource.Token,
        client.GetLong);

    Console.WriteLine(response);
}
catch (TaskCanceledException)
{
    Console.WriteLine("Timeout");
}
    

Here the Helper class has the following code:

    
internal class Helper
{
    public static async Task<T> WithTimeout<T>(TimeSpan timeout, CancellationToken cancellationToken, Func<CancellationToken, Task<T>> action)
    {
        using var cancellationTokenSource = new CancellationTokenSource(timeout);

        using var registration = cancellationToken.Register(cancellationTokenSource.Cancel);

        return await action(cancellationTokenSource.Token);
    }
}
    

Bit in this case, the problem is that the Refit interface is not enough anymore. We have to write some wrapper to call our methods with the desired timeout.

Polly support

Today Polly is the de-facto standard add-on for enterprise-level HTTP requests. Let's see how the library works with HttpClient, RestSharp and Refit.

Here, as in the case of common processing logic, there may be several variants. First of all, the Polly policy may differ for different methods of our client interface. In this case, we can implement it inside our implementation class, and for Refit - through decorator.

Secondly, we may want to set some policy for all methods of one client interface. How can we do this?

For HttpClient it is pretty easy. You create a new policy:

    
var policy = HttpPolicyExtensions
    .HandleTransientHttpError()
    .OrResult(response => (int)response.StatusCode == 418)
    .RetryAsync(3, (_, retry) =>
    {
        AnsiConsole.MarkupLine($"[fuchsia]Retry number {retry}[/]");
    });
    

and assign it to a specific interface:

    
services.AddHttpClient<IServiceClient, ServiceClient>()
    .AddPolicyHandler(policy);
    

For RestSharp, which uses HttpClient from the dependency container, there is no difference.

Refit also supports this scenario quite easily:

    
services
    .AddRefitClient<IServiceClient>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("http://localhost:5001");
    })
    .AddPolicyHandler(policy);
    

It is interesting to consider the following question. What if we have an interface whose almost all methods want one Polly policy, but one method wants a completely different policy? Here, I think we should look at the policy registry and the policy selector. In this article it is described how to select a policy based on specific request.

Request resending

There is one more topic related to Polly. Sometimes we need more complex request preparation. For example, we may need to generate certain headers. In order to do this, the HttpClient class has the Send method that accepts HttpRequestMessage parameter.

However, various problems may occur during the sending of the request. Some of them can be solved by resending the message using, for example, the same Polly policies. But may we pass the same instance of HttpRequestMessage to the Send method again?

To test this possibility, I'll create another endpoint that returns a random result:

    
[HttpGet("rnd")]
public IActionResult GetRandom()
{
    if (Random.Shared.Next(0, 2) == 0)
    {
        return StatusCode(500);
    }

    return Ok();
}
    

Let's take a look at the method of a client communicating with this endpoint. I will not use Polly here, but just make several requests:

    
public async Task<IReadOnlyList<int>> GetRandom()
{
    var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5001/data/rnd");

    var returnCodes = new LinkedList<int>();

    for (int i = 0; i < 10; i++)
    {
        var response = await _httpClient.SendAsync(request);

        returnCodes.AddLast((int)response.StatusCode);
    }

    return returnCodes.ToArray();
}
    

As you can see, I'm trying to send the same instance of HttpRequestMessage multiple times. An what do I have?

    
Unhandled exception. System.InvalidOperationException: The request message was already sent. Cannot send the same request message multiple times.
    

It means that if I need retries, I have to create a new HttpRequestMessage every time.

Now let's test RestSharp. Here is the same repeating request:

    
public async Task<IReadOnlyList<int>> GetRandom()
{
    var client = new RestClient(_httpClient);

    var request = new RestRequest("http://localhost:5001/data/rnd");

    var returnCodes = new LinkedList<int>();

    for (int i = 0; i < 10; i++)
    {
        var response = await client.ExecuteAsync(request);

        returnCodes.AddLast((int)response.StatusCode);
    }

    return returnCodes.ToArray();
}
    

Here instead of HttpRequestMessage we use RestRequest. And this time everything is fine. RestSharp does not mind sending the same RestRequest object multiple times.

For Refit this problem is not applicable. As far as I know, it does not have any analogue of a "request object". All parameters are passed through the arguments of the Refit interface method each time.

Conclusion

It is time to draw some conclusion. Personally, I think that RestSharp is the best option, although its difference from pure HttpClient is minimal. RestSharp uses HttpClient objects and has access to all their configuration options. Only a slightly improved ability to set operation timeout and resend the same request object makes RestSharp the best. Although it should be said that RestSharp requests are slightly slower. For some people, this can be very important.

In my opinion, Refit is somewhat behind. On the one hand, it looks attractive because it does not require writing client code. On the other hand, some scenarios require too much effort to implement.

I hope this comparison was helpful for you. Please write in the comments your experience with these libraries. Or maybe you use something else for HTTP requests?

Good luck!

P.S. The code for this article can be found at GitHub.

No comments:

Post a Comment