A wrapper for HttpClient that caches to disk. Cached files, over the max specified, are deleted based on the last access times.
See Milestones for release notes.
Headers/Responses respected in caching decisions:
https://nuget.org/packages/Replicant/
There is a default static instance:
var content = await HttpCache.Default.DownloadAsync("https://httpbin.org/status/200");This caches to {Temp}/Replicant.
An instance of HttpCache should be long running.
var httpCache = new HttpCache(
cacheDirectory,
// omit for default new HttpClient()
new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
},
// omit for the default of 1000
maxEntries: 10000);
// Dispose when finished
await httpCache.DisposeAsync();Add HttpCache as a singleton when using dependency injection.
var services = new ServiceCollection();
services.AddSingleton(_ => new HttpCache(cacheDirectory));
using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
NotNull(httpCache);Using HttpClient with HttpClientFactory.
var services = new ServiceCollection();
services.AddHttpClient();
services.AddSingleton(_ =>
{
var clientFactory = _.GetRequiredService<IHttpClientFactory>();
return new HttpCache(cacheDirectory, clientFactory.CreateClient);
});
using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
NotNull(httpCache);ReplicantHandler can be used as a DelegatingHandler in the HttpClient pipeline:
var handler = new ReplicantHandler(cacheDirectory)
{
InnerHandler = new HttpClientHandler()
};
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://example.com");ReplicantHandler integrates with HttpClientFactory using AddHttpMessageHandler:
var services = new ServiceCollection();
services.AddHttpClient("CachedClient")
.AddHttpMessageHandler(() => new ReplicantHandler(cacheDirectory));To share a single cache (and purge timer) across multiple named clients, register a ReplicantCache as a singleton:
var services = new ServiceCollection();
services.AddReplicantCache(cacheDirectory);
services.AddHttpClient("CachedClient")
.AddReplicantCaching();ReplicantHandler is a standard DelegatingHandler, so it composes with the Microsoft.Extensions.Http.Resilience package (retry / circuit-breaker / timeout strategies built on Polly).
Do you actually need it? Replicant already ships with built-in retry-with-exponential-backoff via the
maxRetriesparameter on bothHttpCacheandReplicantHandler— see Retry on transient failure. If retries on transient 5xx / 408 / network exceptions are all you need, use that and skip the extra dependency. Reach forMicrosoft.Extensions.Http.Resiliencewhen you also want circuit-breaking, timeouts, hedging, rate limiting, or fine-grained control over which responses are retried.
Always place the cache outside the resilience pipeline:
- Cache hits short-circuit immediately and never consume retry attempts, the timeout budget, or circuit-breaker throughput
- The circuit breaker only observes real upstream failures — cache hits would otherwise look like an artificially healthy server and mask outages
- Retries and timeouts only wrap the actual network call (or conditional GET during revalidation)
So the handler chain is: ReplicantHandler → ResilienceHandler → primary HTTP handler.
IHttpClientBuilder invokes message handlers in registration order, so the first registered handler is the outermost. Register AddReplicantCaching first, then the resilience handler:
var services = new ServiceCollection();
services.AddReplicantCache(cacheDirectory);
services.AddHttpClient("api", _ => _.BaseAddress = new("https://example.com"))
// Register the cache FIRST so it sits OUTERMOST in the pipeline.
// Cache hits short-circuit immediately and never consume retry,
// timeout, or circuit-breaker budget.
.AddReplicantCaching(staleIfError: true)
// Resilience pipeline sits INSIDE the cache, so it only wraps
// actual upstream calls (and conditional GETs during revalidation).
.AddResilienceHandler(
"api-pipeline",
builder => builder
.AddRetry(
new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
})
.AddCircuitBreaker(
new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(10),
FailureRatio = 0.5,
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30),
})
.AddTimeout(TimeSpan.FromSeconds(10)));A shared singleton ReplicantCache matters here because HttpClientFactory recycles handler instances periodically — without it, a fresh CacheStore would be created against the same directory each rotation and throw.
For code that builds an HttpClient by hand, wrap a resilience pipeline in ResilienceHandler (from Microsoft.Extensions.Http.Resilience) and pass it as the inner handler to ReplicantHandler:
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(
new()
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(_ => _.StatusCode >= HttpStatusCode.InternalServerError)
.HandleResult(_ => _.StatusCode == HttpStatusCode.TooManyRequests)
})
.AddCircuitBreaker(
new()
{
SamplingDuration = TimeSpan.FromSeconds(10),
FailureRatio = 0.5,
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(_ => _.StatusCode >= HttpStatusCode.InternalServerError)
})
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();
// Resilience handler wraps the primary HTTP handler...
var resilienceHandler = new ResilienceHandler(pipeline)
{
InnerHandler = new SocketsHttpHandler()
};
// ...and the cache wraps the resilience handler. Cache hits never
// touch the resilience pipeline.
var cachingHandler = new ReplicantHandler(
cacheDirectory,
innerHandler: resilienceHandler,
staleIfError: true);
using var httpClient = new HttpClient(cachingHandler);
var response = await httpClient.GetAsync(
"https://example.com/api/data",
cancel);
response.EnsureSuccessStatusCode();staleIfError: truepairs naturally with the circuit breaker — when the breaker is open andBrokenCircuitExceptionbubbles up, Replicant can serve the last successful response rather than failing outright.ReplicantHandlerhas its ownmaxRetriesparameter. Leave it at the default of0when retries are delegated to the resilience pipeline, to avoid double-retry.- Only
GETandHEADgo through the cache path; other verbs bypassReplicantHandlerand hit the resilience pipeline directly.
Replicant can serve as a disk-based L2 cache for HybridCache. Register ReplicantDistributedCache as the IDistributedCache backend, and HybridCache will automatically use it for its L2 layer (with in-memory L1 handled by HybridCache itself):
var services = new ServiceCollection();
services.AddReplicantDistributedCache(cacheDirectory);
services.AddHybridCache();Only one cache instance (HttpCache, ReplicantCache, or ReplicantHandler with its own directory) can exist per cache directory at any time. Creating a second instance for the same directory will throw an InvalidOperationException. This prevents multiple purge timers from running against the same files.
To share a cache across multiple handlers or consumers, use a single ReplicantCache instance (see above).
var content = await httpCache.StringAsync("https://httpbin.org/json");var lines = new List<string>();
await foreach (var line in httpCache.LinesAsync("https://httpbin.org/json"))
{
lines.Add(line);
}var bytes = await httpCache.BytesAsync("https://httpbin.org/json");using var stream = await httpCache.StreamAsync("https://httpbin.org/json");await httpCache.ToFileAsync("https://httpbin.org/json", targetFile);await httpCache.ToStreamAsync("https://httpbin.org/json", targetStream);using var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("the content")
};
await httpCache.AddItemAsync(uri, response);If an error occurs when re-validating a potentially stale item, then the cached item can be used as a fallback.
var content = httpCache.StringAsync(uri, staleIfError: true);By default, 404 responses are not cached and throw an exception. Set cache404: true on the constructor to cache 404 Not Found responses to disk, avoiding repeated requests for resources known to be missing. The original 404 status code is preserved in the cached metadata.
await using var cache = new HttpCache(cacheDirectory, cache404: true);
var content = await cache.StringAsync(uri);Transient HTTP failures and network exceptions are automatically retried with exponential backoff when maxRetries is set. This works with both HttpCache and ReplicantHandler.
Retried status codes:
408Request Timeout500Internal Server Error502Bad Gateway503Service Unavailable504Gateway Timeout
using var httpCache = new HttpCache(cacheDirectory, maxRetries: 3);
var content = await httpCache.StringAsync("https://example.com");var handler = new ReplicantHandler(cacheDirectory, maxRetries: 3)
{
InnerHandler = new HttpClientHandler()
};
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://example.com");Retries use exponential backoff (200ms, 400ms, 800ms, ...). When combined with staleIfError, retries are attempted first; if all retries are exhausted, stale cached content is returned as a fallback.
By default, cached entries are revalidated when the server-provided expiry (from Expires or Cache-Control: max-age) has passed. When no expiry header is present, cached entries are served indefinitely without revalidation.
For servers that set short expiry times on content that rarely or never changes (e.g. symbol servers, package registries), set minFreshness to override the server's expiry and keep cached entries fresh for a minimum duration. This avoids unnecessary conditional GET round-trips for immutable content. This is a client-side form of heuristic freshness — useful when the server doesn't send Cache-Control: immutable or long-lived expiry headers.
await using var cache = new HttpCache(
cacheDirectory,
minFreshness: TimeSpan.FromHours(1));
var content = await cache.StringAsync("https://httpbin.org/json");The HttpRequestMessage used can be customized using a callback.
var content = await httpCache.StringAsync(
uri,
modifyRequest: message =>
{
message.Headers.Add("Key1", "Value1");
message.Headers.Add("Key2", "Value2");
});An instance of the HttpResponseMessage can be created from a cached item:
using var response = await httpCache.ResponseAsync("https://httpbin.org/status/200");graph TD
IHttpCache["IHttpCache<br/>(interface)"]
HttpCache["HttpCache<br/>implements IHttpCache"]
ReplicantHandler["ReplicantHandler<br/>extends DelegatingHandler"]
ReplicantCache["ReplicantCache"]
CacheSession["CacheSession"]
CacheStore["CacheStore"]
FilePair["FilePair<br/>(struct)"]
Timestamp["Timestamp<br/>(struct)"]
MetaData["MetaData"]
Result["Result<br/>(struct)"]
DeriveCacheStatus["DeriveCacheStatus"]
CacheStatus["CacheStatus<br/>(enum)"]
ReplicantDistributedCache["ReplicantDistributedCache<br/>implements IDistributedCache"]
HybridCache["HybridCache<br/>(L1 memory + L2 disk)"]
HttpCache -->|implements| IHttpCache
HttpCache -->|owns| CacheStore
HttpCache -->|creates per call| CacheSession
HttpCache -->|returns| Result
ReplicantHandler -->|owns or references| CacheSession
ReplicantHandler -->|can share| ReplicantCache
ReplicantCache -->|owns| CacheStore
CacheSession -->|uses| CacheStore
CacheSession -->|uses| DeriveCacheStatus
DeriveCacheStatus -->|returns| CacheStatus
CacheStore -->|manages| FilePair
CacheStore -->|uses| Timestamp
CacheStore -->|serializes| MetaData
Result -->|holds| FilePair
Timestamp -->|encodes| FilePair
HybridCache -->|L2 backend| ReplicantDistributedCache
HttpCache is the standalone API that owns an HttpClient. ReplicantHandler is a DelegatingHandler that plugs into an existing HttpClient pipeline. Both delegate to CacheSession which orchestrates the cache protocol using CacheStore for disk I/O. When multiple handlers need to share a cache, a single ReplicantCache is registered as a singleton. ReplicantDistributedCache implements IDistributedCache for use as a disk-based L2 backend with HybridCache.
graph TD
Request[Incoming HTTP Request]
IsGetOrHead{GET or HEAD?}
Passthrough[Pass through to server]
CacheExists{Cached file<br/>exists for URI?}
SendNew[Send request to server]
IsSuccess{Response 2xx?}
Is404{HTTP 404?}
IsCache404{cache404<br/>enabled?}
ThrowException[Throw exception]
IsNoStoreNew{Cache-Control:<br/>no-store?}
ReturnDirect[Return response directly<br/>nothing cached]
Store[Store response to disk]
ReturnCached[Return content from cache]
IsExpired{Expired?<br/>file last-write-time<br/>vs now}
CacheHit[Cache hit<br/>return cached content]
SendConditional["Send conditional request<br/>If-Modified-Since: {last-modified}<br/>If-None-Match: {etag}"]
IsNetworkError{Network error?}
IsStaleIfError{staleIfError<br/>enabled?}
ReturnStale[Return stale<br/>cached content]
IsNoStore{Cache-Control:<br/>no-store?}
IsNoCache{Cache-Control:<br/>no-cache?}
StoreRevalidate[Store response<br/>always revalidate next time]
IsNotModified{HTTP 304<br/>Not Modified?}
IsSuccessRevalidate{HTTP 2xx?}
Is404Revalidate{HTTP 404?}
IsCache404Revalidate{cache404<br/>enabled?}
IsStaleIfErrorRevalidate{staleIfError<br/>enabled?}
Request --> IsGetOrHead
IsGetOrHead -->|No| Passthrough
IsGetOrHead -->|Yes| CacheExists
CacheExists -->|No| SendNew
SendNew --> IsSuccess
IsSuccess -->|No| Is404
Is404 -->|Yes| IsCache404
IsCache404 -->|Yes| Store
IsCache404 -->|No| ThrowException
Is404 -->|No| ThrowException
IsSuccess -->|Yes| IsNoStoreNew
IsNoStoreNew -->|Yes| ReturnDirect
IsNoStoreNew -->|No| Store
Store --> ReturnCached
CacheExists -->|Yes| IsExpired
IsExpired -->|Not expired| CacheHit
IsExpired -->|Expired| IsMinFresh
IsMinFresh{minFreshness set<br/>and file created<br/>within window?}
IsMinFresh -->|Yes| CacheHit
IsMinFresh -->|No| SendConditional
SendConditional --> IsNetworkError
IsNetworkError -->|Yes| IsStaleIfError
IsStaleIfError -->|Yes| ReturnStale
IsStaleIfError -->|No| ThrowException
IsNetworkError -->|No| IsNoStore
IsNoStore -->|Yes| ReturnDirect
IsNoStore -->|No| IsNoCache
IsNoCache -->|Yes| StoreRevalidate
StoreRevalidate --> ReturnCached
IsNoCache -->|No| IsNotModified
IsNotModified -->|Yes| CacheHit
IsNotModified -->|No| IsSuccessRevalidate
IsSuccessRevalidate -->|Yes| Store
IsSuccessRevalidate -->|No| Is404Revalidate
Is404Revalidate -->|Yes| IsCache404Revalidate
IsCache404Revalidate -->|Yes| Store
IsCache404Revalidate -->|No| IsStaleIfErrorRevalidate
Is404Revalidate -->|No| IsStaleIfErrorRevalidate
IsStaleIfErrorRevalidate -->|Yes| ReturnStale
IsStaleIfErrorRevalidate -->|No| ThrowException
When storing a response, the cache expiry is derived from response headers in this order:
Expiresheader — used as the absolute expiry timeCache-Control: max-age— expiry = now + max-age- Neither present — no expiry, file last-write-time set to min date (always revalidate)
The expiry is persisted as the cached file's last-write-time in the filesystem.
When a cached entry has expired, a conditional request is sent with:
If-Modified-Since— from theLast-Modifiedvalue stored in the cache filenameIf-None-Match— from theETagvalue stored in the cache filename (if present)
If the server responds 304 Not Modified, the cached content is reused without re-downloading.
Cyborg designed by Symbolon from The Noun Project.