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;
}
}
}
- We log some information about the request we are sending
- We start a new stopwatch
- We call base.SendAsync (next DelegatingHandler in the pipeline)
- We stop the stopwatch and log the time taken
- 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.