Caching HttpClient Requests
Introduction
The HttpClient class is Microsoft’s recommended approach for making raw HTTP calls in .NET. It allows you to send arbitrary HTTP requests (including headers) through a request/response pipeline of message handlers. These handlers can be used to augment the request and response messages and add additional policies, such as retrying operations, handling errors, and so on. One thing that is missing from the out-of-the box behaviours is caching of GET requests (only ones that can be cached!), and that’s what I’ll be talking about here. The concepts introduced in this post will be used in a few days in another one, so please keep an open eye!
In-Memory Cache
Most people will be familiar with .NET’s IMemoryCache abstraction and implementations, from the System.Runtime.Caching Nuget package. It features a very simple in-memory implementation with some basic operations. We usually add the implementation to the dependency injection by using the AddMemoryCache() method:
builder.Services.AddMemoryCache();
There are a few other options, that you can read about here, but essentially, this is it. We can now get named items from the cache and add/delete named items to the cache, I won't cover these here.
Configuring and Retrieving HttpClients
The management of the lifetime of an HttpClient amongst requests is somewhat complex to deal with, so it is recommended that we let .NET take care of it. There are a few ways to do this, but, in a nutshell, we register an HttpClient to the dependency injection with the AddHttpClient() method, either named or unnamed:
//named HttpClient
builder.Services.AddHttpClient("Todos", httpClient =>
{
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/todos/");
httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
});
//unnamed HttpClient
builder.Services.AddHttpClient(httpClient =>
{
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/todos/");
httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
});
Named clients allow us to have as many as we want, configured possibly in different ways, whereas unnamed clients can only have one configuration.
And then we just request clients from an injected IHttpClientFactory:
public IActionResult Index([FromServices] IHttpClientFactory factory)
{
var httpClient = factory.CreateClient("Todos");
}
//unnamed HttpClient
public IActionResult Index([FromServices] IHttpClientFactory factory)
{
var httpClient = factory.CreateClient();
}
Also, keep in mind that we should NOT dispose of the client!
It is also possible to register strongly typed clients, meaning, classes that wrap the HttpClient and expose methods that allow us to send and receive HTTP messages transparently. There is an overload of AddHttpClient just for that:
builder.Services.AddHttpClient<TodoClient>();
Where TodoClient is implemented like this:
public class TodoClient
{
private readonly HttpClient _httpClient;
public TodoClient(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/todos/");
_httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
}
public async Task<Todo> Get(int id, CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<Todo>($"{id}", cancellationToken);
}
}
public record Todo(int Id, int UserId, string Title, bool Completed);
The usage is even simpler: just inject an instance of TodoClient, it is registered in dependency injection. If you're wondering, I'm using the free {JSON} Placeholder service here!
If you want to have it implement some interface, there's an overload for that:
public interface ITodoClient
{
Task<Todo> Get(int id, CancellationToken cancellationToken);
}
public class TodoClient : ITodoClient { ... }
builder.Services.AddHttpClient<ITodoClient, TodoClient>();
And we can combine also the two: configure an HttpClient and associate it with a typed client:
builder.Services.AddHttpClient("Todos", httpClient =>
{
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/todos/");
httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
}).AddTypedClient<TodoClient>();
In this case, the code for the TodoClient constructor can be simplified, because it gets the definitions from the AddHttpClient call:
public class TodoClient
{
private readonly HttpClient _httpClient;
public TodoClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
//...
}
Keep in mind that you cannot inject an IHttpClientFactory into a typed client registered using AddHttpClient, it just doesn't work. Inject the HttpClient instead, it will be the right one if you use AddTypedClient!
Making HTTP Requests
Now that we have a client instance, we can make requests using it. Chances are, nowadays, that the requests will contain or return JSON, so fortunately there are a few extension methods that we can leverage that underneath make use of the System.Text.Json API, like we’ve seen in the strongly-typed client:
var id = 1;
var todo = await httpClient.GetFromJsonAsync<Todo>($"{id}"); //skipping the CancellationToken parameter
The GetFromJsonAsync extension method takes care of turning the response string into the target type. There are overloads that take a CancellationToken, to allow cancelling long-running operations that may have been aborted, and/or a JsonSerializerOptions, to control the deserialisation from JSON.
Message Handlers
A message handler is a class that can be added to the pipeline of an HttpClient and then be used to perform operations on the request and response messages. There are different kinds of handlers, but they all inherit from HttpMessageHandler. An handler must be added to the pipeline as this:
builder.Services.AddHttpClient("Todos", httpClient => {
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/todos/"); httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json); }).AddHttpMessageHandler<CachingHandler>();
The CachingHandler class will be discussed shortly. For typed clients, its the same:
builder.Services.AddHttpClient<TodoClient>().AddHttpMessageHandler<CachingHandler>();
And you can add as many as you want, just call again AddHttpMessageHandler() immediately after the previous call.
Caching Message Handler
Now that we’ve seen the basics, let’s see how we can create the CachingHandler class. What we want is:
- For GET requests, we want to see if, for the current request URL, we have an entry on the cache
- If so, we just return it and short-circuit the processing
- If not, we send the request in the normal way, and, after we have the response from the server, we add it to the cache
Here is the CachingHandler class:
public class CachingHandler : DelegatingHandler {
private static readonly TimeSpan _defaultDuration = TimeSpan.FromHours(1);
private readonly IMemoryCache _cache;
private readonly ILogger<CachingHandler> _logger;
private readonly TimeSpan _duration;
public CachingHandler(IMemoryCache cache, ILogger<CachingHandler> logger) : this(cache, logger, _defaultDuration) { }
public CachingHandler(IMemoryCache cache, ILogger<CachingHandler> logger, IOptions<CachingHandlerOption> options) : this(cache, logger)
{
if (options?.Value?.CacheDuration != null)
{
_duration = options.Value.CacheDuration.Value;
}
}
public CachingHandler(IMemoryCache cache, ILogger<CachingHandler> logger, TimeSpan duration)
{
_cache = cache;
_logger = logger;
_duration = duration;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(request.RequestUri!, out var response) && response is HttpResponseMessage res)
{
_logger.LogInformation($"Getting response from cache for {request.RequestUri}");
}
else
{
res = await base.SendAsync(request, cancellationToken);
res.EnsureSuccessStatusCode();
res.Content = new NoDisposeStreamContent(res.Content);
_cache.Set(request.RequestUri!, res, DateTimeOffset.Now.Add(_duration));
_logger.LogInformation($"Adding response for {request.RequestUri} to cache for {_duration}");
}
return res;
} }
public class CachingHandlerOptions
{
public TimeSpan? CacheDuration { get; set; }
}
The CachingHandler class is implemented as a DelegatingHandler, a special kind of message handler that inherits from HttpMessageHandler and is more suitable for processing responses from an inner (in this case, the default) handler. The SendAsync method is pretty basic, we inspect the cache for the existence of an entry for RequestUri, and that it is an HttpResponseMessage, and if so, we just return it; otherwise, it’s business as usual, we just need to cache the response before returning it. There's one thing important about it, though: we are replacing the Content property with our own class! This class is required because the original one is not meant to be reused, and it is disposed when consumed. Here is its code:
class NoDisposeStreamContent : HttpContent
{
private byte[] _buffer;
class NoDisposeMemoryStream : MemoryStream
{
public NoDisposeMemoryStream(byte[] buffer) : base(buffer) { }
private void Reset()
{
Position = 0;
}
protected override void Dispose(bool disposing)
{
Reset();
}
public override ValueTask DisposeAsync()
{
Reset();
return ValueTask.CompletedTask;
}
}
public NoDisposeStreamContent(HttpContent content)
{
ArgumentNullException.ThrowIfNull(content, nameof(content));
_buffer = content.ReadAsByteArrayAsync().ConfigureAwait(false).GetAwaiter().GetResult();
foreach (var headers in content.Headers)
{
this.Headers.TryAddWithoutValidation(headers.Key, headers.Value);
}
}
protected override void Dispose(bool disposing)
{
}
protected async Task CopyToStreamAsync(Stream stream)
{
await stream.WriteAsync(_buffer, 0, _buffer.Length);
}
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
await CopyToStreamAsync(stream);
}
protected override bool TryComputeLength(out long length)
{
length = _buffer.Length;
return true;
}
protected override Task<Stream> CreateContentReadStreamAsync()
{
return Task.FromResult<Stream>(new NoDisposeMemoryStream(_buffer));
}
}
This class does some weird stuff, like copying from the original content into a local buffer, including the headers, and returning a new stream whenever requested. Rest assured, it is working!
And this is really all there is to it! To make our lives easier, a couple extension methods come in handy:
public static class HttpClientBuilderExtensions {
public static IHttpClientBuilder AddCache(this IHttpClientBuilder builder)
{
builder.Services.AddTransient<CachingHandler>();
return builder.AddHttpMessageHandler<CachingHandler>();
}
public static IHttpClientBuilder AddCache(this IHttpClientBuilder builder, TimeSpan duration)
{
builder.Services.AddTransient<CachingHandler>(sp => new CachingHandler(sp.GetRequiredService<IMemoryCache>(), sp.GetRequiredService<ILogger<CachingHandler>>(), duration));
return builder.AddHttpMessageHandler<CachingHandler>();
}
public static IHttpClientBuilder AddCache(this IHttpClientBuilder builder, Action<CachingHandlerOptions> options)
{
ArgumentNullException.ThrowIfNull(options, nameof(options));
var opt = mew CachingHandlerOptions();
options(opt);
builder.Services.AddTransient<CachingHandler>(sp => new CachingHandler(sp.GetRequiredService<IMemoryCache>(), sp.GetRequiredService<ILogger<CachingHandler>>(), Options.Create(opt)));
return builder.AddHttpMessageHandler<CachingHandler>();
}
}
The second overload allows us to pass the duration for the cache, if we don’t want to use the default one. Notice the implementation of the Options pattern.
And as for the dependency injection registrations:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddTodoClient(this IServiceCollection services)
{
services.AddHttpClient<TodoClient>().AddCache();
return services;
}
public static IServiceCollection AddTodoClient(this IServiceCollection services, TimeSpan duration)
{
services.AddHttpClient<TodoClient>().AddCache(duration);
return services;
}
}
Conclusion
We’ve seen how to configure an HttpClient, use message handlers and memory cache to implement a cache for GET requests. I may publish this to Nuget (the caching handler and extension method), in which case, I will update this post. Let me know if you have any questions!