Skip to content

SimonCropp/Replicant

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

935 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Replicant

Build status NuGet Status

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:

NuGet package

https://nuget.org/packages/Replicant/

Usage

Default instance

There is a default static instance:

var content = await HttpCache.Default.DownloadAsync("https://httpbin.org/status/200");

snippet source | anchor

This caches to {Temp}/Replicant.

Construction

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();

snippet source | anchor

Dependency injection

Add HttpCache as a singleton when using dependency injection.

var services = new ServiceCollection();
services.AddSingleton(_ => new HttpCache(diPath));

using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
NotNull(httpCache);

snippet source | anchor

Using HttpClient with HttpClientFactory.

var services = new ServiceCollection();
services.AddHttpClient();
services.AddSingleton(
    _ =>
    {
        var clientFactory = _.GetRequiredService<IHttpClientFactory>();
        return new HttpCache(diPath, clientFactory.CreateClient);
    });

using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
NotNull(httpCache);

snippet source | anchor

DelegatingHandler

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");

snippet source | anchor

DelegatingHandler with HttpClientFactory

ReplicantHandler integrates with HttpClientFactory using AddHttpMessageHandler:

var services = new ServiceCollection();
services.AddHttpClient("CachedClient")
    .AddHttpMessageHandler(() => new ReplicantHandler(cacheDirectory));

snippet source | anchor

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();

snippet source | anchor

HybridCache support

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();

snippet source | anchor

Single cache per directory

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).

Get a string

var content = await httpCache.StringAsync("https://httpbin.org/json");

snippet source | anchor

var lines = new List<string>();
await foreach (var line in httpCache.LinesAsync("https://httpbin.org/json"))
{
    lines.Add(line);
}

snippet source | anchor

Get bytes

var bytes = await httpCache.BytesAsync("https://httpbin.org/json");

snippet source | anchor

Get a stream

using var stream = await httpCache.StreamAsync("https://httpbin.org/json");

snippet source | anchor

Download to a file

await httpCache.ToFileAsync("https://httpbin.org/json", targetFile);

snippet source | anchor

Download to a stream

await httpCache.ToStreamAsync("https://httpbin.org/json", targetStream);

snippet source | anchor

Manually add an item to the cache

using var response = new HttpResponseMessage(HttpStatusCode.OK)
{
    Content = new StringContent("the content")
};
await httpCache.AddItemAsync(uri, response);

snippet source | anchor

Use stale item on error

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);

snippet source | anchor

Customizing HttpRequestMessage

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");
    });

snippet source | anchor

Full HttpResponseMessage

An instance of the HttpResponseMessage can be created from a cached item:

using var response = await httpCache.ResponseAsync("https://httpbin.org/status/200");

snippet source | anchor

Architecture

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
Loading

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.

Cache decision flow

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?}
    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?}
    IsStaleIfErrorRevalidate{staleIfError<br/>enabled?}

    Request --> IsGetOrHead
    IsGetOrHead -->|No| Passthrough
    IsGetOrHead -->|Yes| CacheExists
    CacheExists -->|No| SendNew
    SendNew --> IsSuccess
    IsSuccess -->|No| ThrowException
    IsSuccess -->|Yes| IsNoStoreNew
    IsNoStoreNew -->|Yes| ReturnDirect
    IsNoStoreNew -->|No| Store
    Store --> ReturnCached
    CacheExists -->|Yes| IsExpired
    IsExpired -->|Not expired| CacheHit
    IsExpired -->|Expired| 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| IsStaleIfErrorRevalidate
    IsStaleIfErrorRevalidate -->|Yes| ReturnStale
    IsStaleIfErrorRevalidate -->|No| ThrowException
Loading

How expiry is determined

When storing a response, the cache expiry is derived from response headers in this order:

  1. Expires header — used as the absolute expiry time
  2. Cache-Control: max-age — expiry = now + max-age
  3. 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.

Conditional request headers

When a cached entry has expired, a conditional request is sent with:

  • If-Modified-Since — from the Last-Modified value stored in the cache filename
  • If-None-Match — from the ETag value stored in the cache filename (if present)

If the server responds 304 Not Modified, the cached content is reused without re-downloading.

Influences / Alternatives

Icon

Cyborg designed by Symbolon from The Noun Project.

About

A wrapper for HttpClient that caches to disk. Cached files, over the max specified, are deleted based on the last access times.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Sponsor this project

 

Contributors

Languages