Photo by CDC

Introduction

This is just a quick tip for anyone using HttpClient and wants to avoid some of the boilerplate (error handling, logging etc).

Usually my implementations looks something like this:

public class DummyApiHttpClient
{
    private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General)
    {
        PropertyNameCaseInsensitive = true
    };

    private readonly HttpClient _httpClient;
    private readonly ILogger<DummyApiHttpClient> _logger;

    public DummyApiHttpClient(HttpClient httpClient, ILogger<DummyApiHttpClient> logger)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<IReadOnlyCollection<WeatherForecastResponse>> GetWeatherForecast(string location)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, $"/weatherforecast?location={location}");
        var stopwatch = Stopwatch.StartNew();
        try
        {    
            using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
            stopwatch.Stop();
            response.EnsureSuccessStatusCode();
            await using var responseContent = await response.Content.ReadAsStreamAsync();

            return await JsonSerializer.DeserializeAsync<IReadOnlyCollection<WeatherForecastResponse>>(responseContent, DefaultJsonSerializerOptions);
        }
        catch (Exception exception) when (exception is TaskCanceledException || (exception is TimeoutException))
        {
            stopwatch.Stop();
            _logger.LogError("Timeout during {RequestMethod} to {RequestUri} after {ElapsedMilliseconds}ms", request.Method, request.RequestUri?.ToString(), stopwatch.ElapsedMilliseconds);
            throw;
        }
        catch (Exception e)
        {
            stopwatch.Stop();
            _logger.LogError(e, "Error during {HttpMethod} to {RequestUri} after {ElapsedMilliseconds}ms", request.Method, request.RequestUri?.ToString());
            throw;
        }
    }
}
  • We create a HttpRequestMessage.
  • Start a Stopwatch.
  • Ensuring that the status code is successful.
  • Getting the response content and deserialize it.
  • Catch exceptions -> log -> throw

This is completely fine but I find it a bit tedious to write the same code over and over again.

One solution is to create a base class and inherit from it but in my experience that is almost never the best solution. You might want to use a different HttpCompletionOption in one specific call, or you might want to read the response body as a string instead of a stream in another and so on. It can quickly be a mess with a bunch of optional properties and so on.

So let's try another way.

DelegatingHandler

Previously I've written about using a custom HttpMessageHandler during unit testing, now the time has come to use a DelegatingHandler.

There's a bunch of different posts about this topic already so I will not cover what they are or how they work. I will just show you how to use them. If you want to get a more in depth understanding of DelegatingHandler I recommend this post by Steve Gordon.

Let's get into it. We will create a custom DelegatingHandler that will handle:

  • Logging.
  • Timing (stopwatch).
  • Ensuring a successful http status code.
public class DefaultHttpDelegatingHandler : DelegatingHandler
{
    private readonly ILogger<DefaultHttpDelegatingHandler> _logger;

    public DefaultHttpDelegatingHandler(ILogger<DefaultHttpDelegatingHandler> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Sending {RequestMethod} request towards {Request}", request.Method, request?.RequestUri?.ToString());
        var stopwatch = Stopwatch.StartNew();
        HttpResponseMessage response = null;
        try
        {
            response = await base.SendAsync(request, cancellationToken);
            stopwatch.Stop();
            _logger.LogInformation("Response took {ElapsedMilliseconds}ms {StatusCode}", response.StatusCode, stopwatch.ElapsedMilliseconds, response.StatusCode);
            response.EnsureSuccessStatusCode();
            return response;
        }
        catch (Exception exception) when (exception is TaskCanceledException || (exception is TimeoutException))
        {
            stopwatch.Stop();
            _logger.LogError(exception,
                "Timeout during {RequestMethod} to {RequestUri} after {ElapsedMilliseconds}ms {StatusCode}",
                request.Method,
                request.RequestUri?.ToString(),
                stopwatch.ElapsedMilliseconds,
                response?.StatusCode);
            throw;
        }
        catch (Exception exception)
        {
            stopwatch.Stop();
            _logger.LogError(exception,
                "Exception during {RequestMethod} to {RequestUri} after {ElapsedMilliseconds}ms {StatusCode}",
                request.Method,
                request.RequestUri?.ToString(),
                stopwatch.ElapsedMilliseconds,
                response?.StatusCode);
            throw;
        }
    }
}
  1. We log some information about the request we are sending
  2. We start a new stopwatch
  3. We call base.SendAsync (next DelegatingHandler in the pipeline)
  4. We stop the stopwatch and log the time taken
  5. Log errors -> throw

To use our DefaultHttpDelegatingHandler we need to change the registration of our DummyApiHttpClient.

...

services.AddTransient<DefaultHttpDelegatingHandler>();
services.AddHttpClient<DummyApiHttpClient>((client) =>
{
    client.BaseAddress = new Uri("http://localhost:5000");
}).AddHttpMessageHandler<DefaultHttpDelegatingHandler>();
...

The important part here is the call to AddHttpMessageHandler.

We can now remove some code from our DummyApiHttpClient and make it a bit cleaner:

public class DummyApiHttpClient
{
    private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General)
    {
        PropertyNameCaseInsensitive = true
    };

    private readonly HttpClient _httpClient;

    public DummyApiHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<IReadOnlyCollection<WeatherForecastResponse>> GetWeatherForecast(string location)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, $"/weatherforecast?location={location}");
        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        await using var responseContent = await response.Content.ReadAsStreamAsync();
        return await JsonSerializer.DeserializeAsync<IReadOnlyCollection<WeatherForecastResponse>>(responseContent, DefaultJsonSerializerOptions);
    }
}

We can then choose if we want to handle the exceptions here or not, I will just let it propagate further up the chain and deal with it there, for example in a custom exception handler.

The code is now a bit cleaner, the only "drawback" is that it is now not immediately clear that we are logging and timing all requests since that logic is now hidden away in a custom DelegatingHandler, but I think thats a fair trade-off.

This was just a quick tip demonstrating how to use a custom DelegatingHandler. Remember that you can do all kind of things with this, you can chain a bunch of custom DelegatingHandlers for example, one could be responsible for timing, another one for logging...you get the idea :)

All code used in this post can be found here.