Skip to content

Caching HttpHandler based on RFC 9111 that uses HybridCache

License

Notifications You must be signed in to change notification settings

damianh/http-hybridcache-handler

Repository files navigation

HttpHybridCache Handler

CI License .NET GitHub Stars

RFC 9111 compliant client-side HTTP caching for HttpClient, powered by .NET's HybridCache for efficient L1 (memory) and L2 (distributed) caching.

Packages

Package Description NuGet Downloads
DamianH.HttpHybridCacheHandler RFC 9111 client-side HTTP caching handler for HttpClient NuGet Downloads
DamianH.FileDistributedCache File-based IDistributedCache / IBufferDistributedCache for zero-infrastructure persistent caching NuGet Downloads

Note

Project is new and is being dog-fooded. If you try it out feedback would be appreciated!

Table of Contents

HttpHybridCacheHandler

FileDistributedCache

General

Features

Package: DamianH.HttpHybridCacheHandler — A caching DelegatingHandler for HttpClient that provides client-side HTTP caching based on RFC 9111.

Core Caching Capabilities

  • RFC 9111 Compliant: Full implementation of HTTP caching specification for client-side caching
  • HybridCache Integration: Leverages .NET's HybridCache for efficient L1 (memory) and L2 (distributed) caching
  • Transparent Operation: Works seamlessly with existing HttpClient code

Cache-Control Directives

Request Directives:

  • max-age: Control maximum acceptable response age
  • max-stale: Accept stale responses within specified staleness tolerance
  • min-fresh: Require responses to remain fresh for specified duration
  • no-cache: Force revalidation with origin server
  • no-store: Bypass cache completely
  • only-if-cached: Return cached responses or 504 if not cached

Response Directives:

  • max-age: Define response freshness lifetime
  • no-cache: Store but require validation before use
  • no-store: Prevent caching
  • public/private: Control cache visibility
  • must-revalidate: Enforce validation when stale

Advanced Features

  • Conditional Requests: Automatic ETag (If-None-Match) and Last-Modified (If-Modified-Since) validation
  • Vary Header Support: Content negotiation with multiple cache entries per resource
  • Freshness Calculation: Supports Expires header, Age header, and heuristic freshness (Last-Modified based)
  • Stale Response Handling:
    • stale-while-revalidate: Serve stale content while updating in background
    • stale-if-error: Serve stale content when origin is unavailable
  • Configurable Limits: Per-item content size limits (default 10MB)
  • Metrics: Built-in metrics via System.Diagnostics.Metrics for hit/miss rates and cache operations
  • Custom Cache Keys: Extensible cache key generation for advanced scenarios
  • Request Collapsing: Prevents cache stampede via HybridCache.GetOrCreateAsync automatic request coalescing

Installation

dotnet add package DamianH.HttpHybridCacheHandler

Quick Start

Basic Usage with Recommended Configuration

var services = new ServiceCollection();

services.AddHttpHybridCacheHandler(options =>
{
    options.FallbackCacheDuration = TimeSpan.FromMinutes(5);
    options.MaxCacheableContentSize = 10 * 1024 * 1024; // 10MB
    options.CompressionThreshold = 1024; // Compress cached content >1KB
});

services.AddHttpClient("MyClient")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        // Enable automatic decompression - server compression handled transparently
        AutomaticDecompression = DecompressionMethods.All,
        
        // DNS refresh every 5 minutes - critical for cloud/microservices
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        
        // Close idle connections after 2 minutes
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
        
        // Reasonable connection timeout
        ConnectTimeout = TimeSpan.FromSeconds(10)
    })
    .AddHttpMessageHandler(sp => sp.GetRequiredService<HttpHybridCacheHandler>());

var client = services.BuildServiceProvider()
    .GetRequiredService<IHttpClientFactory>()
    .CreateClient("MyClient");

var response = await client.GetAsync("https://api.example.com/data");

Handler Pipeline Configuration

Recommended Setup

Always use SocketsHttpHandler with AutomaticDecompression enabled (better performance, DNS refresh, and connection pooling than legacy HttpClientHandler):

.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    AutomaticDecompression = DecompressionMethods.All,
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2)
})

AutomaticDecompression Explained

Two different compressions:

  1. Transport Compression (Server → Client)

    • Controlled by: AutomaticDecompression on SocketsHttpHandler
    • Purpose: Reduce network bandwidth
    • Result: Handler receives decompressed content
  2. Cache Storage Compression (This library)

    • Controlled by: CompressionThreshold in options
    • Purpose: Reduce cache storage size
    • Result: Content compressed before storing in cache

Example Flow:

Server sends: gzipped 512 bytes
    ↓
SocketsHttpHandler: auto-decompresses → 2048 bytes
    ↓
HttpHybridCacheHandler: receives decompressed content
    ↓
Our compression: compresses → 600 bytes
    ↓
Cache: stores 600 bytes (no Base64 overhead!)

Benefits:

  • Cache handler can inspect and validate response content
  • Cache-Control, ETag, and Last-Modified headers are readable
  • Enables intelligent caching decisions
  • Storage compression is optional and configurable

Handler Ordering

Pipeline structure:

HttpClient → [Outer Handlers] → HttpHybridCacheHandler → SocketsHttpHandler → Network

With Polly Resilience (Recommended for Production)

.AddHttpMessageHandler(sp => new HttpHybridCacheHandler(...))
.AddStandardResilienceHandler(options =>
{
    options.Retry.MaxRetryAttempts = 3;
    options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
});

Order: Polly (outer) → Cache → SocketsHttpHandler

Why: Cache hit = fast path, Polly never invoked. Cache miss + network failure = Polly retries.

With Authentication

.AddHttpMessageHandler(() => new AuthenticationHandler())
.AddHttpMessageHandler(sp => sp.GetRequiredService<HttpHybridCacheHandler>());

To include auth headers in cache key, configure VaryHeaders in the AddHttpHybridCacheHandler options.

Auth applied before caching, headers included in cache key via Vary.

Common Mistakes

Wrong: Not enabling AutomaticDecompression

new SocketsHttpHandler()  // Defaults to None!

Problem: Cache handler receives compressed content, can't inspect properly.

Correct: Explicitly enable decompression

new SocketsHttpHandler
{
    AutomaticDecompression = DecompressionMethods.All
}

Wrong: Using legacy HttpClientHandler

new HttpClientHandler()  // Legacy, less efficient

Correct: Use modern SocketsHttpHandler

new SocketsHttpHandler { /* ... */ }

Wrong: Cache handler after Polly

.AddStandardResilienceHandler()  // Outer
.AddHttpMessageHandler(sp => new HttpHybridCacheHandler(...))  // Inner - Wrong!

Correct: Cache handler before Polly

.AddHttpMessageHandler(sp => new HttpHybridCacheHandler(...))  // Inner - Correct!
.AddStandardResilienceHandler()  // Outer

Golden Rule: HttpHybridCacheHandler should receive decompressed, ready-to-use content.

Configuration Options

Cache Mode

The library supports two cache modes, following RFC 9111 semantics:

CacheMode.Private (Default)

Browser-like cache behavior suitable for client applications:

Use Cases:

  • HttpClient in web applications, APIs, background services
  • Scaled-out clients sharing cache (multiple instances, serverless/Lambda)
  • Per-user/per-tenant caching scenarios

Behavior:

  • Caches responses with Cache-Control: private
  • Uses max-age directive (ignores s-maxage)
  • Caches authenticated requests if marked private or max-age
  • Each cache key is client-specific (Vary headers applied)

Example:

new HttpHybridCacheHandlerOptions
{
    Mode = CacheMode.Private, // Shares cache across app instances via Redis L2
    FallbackCacheDuration = TimeSpan.FromMinutes(5)
}

CacheMode.Shared

Proxy/CDN-like cache behavior suitable for gateways:

Use Cases:

  • Reverse proxies (YARP, Envoy)
  • API gateways
  • Edge caches / CDN-like scenarios

Behavior:

  • Does NOT cache responses with Cache-Control: private
  • Prefers s-maxage over max-age
  • Only caches authenticated requests with public or s-maxage
  • Cache is shared across all clients/users

Example:

new HttpHybridCacheHandlerOptions
{
    Mode = CacheMode.Shared, // RFC 9111 shared cache semantics
    MaxCacheableContentSize = 50 * 1024 * 1024 // 50MB
}

HttpHybridCacheHandlerOptions

  • Mode: Cache mode determining caching behavior (default: CacheMode.Private). Use CacheMode.Shared for proxy/CDN scenarios
  • HeuristicFreshnessPercent: Heuristic freshness percentage for responses with Last-Modified but no explicit freshness info (default: 0.1 or 10%)
  • VaryHeaders: Headers to include in Vary-aware cache keys (default: Accept, Accept-Encoding, Accept-Language, User-Agent)
  • MaxCacheableContentSize: Maximum size in bytes for cacheable response content (default: 10 MB). Responses larger than this will not be cached
  • FallbackCacheDuration: Fallback cache duration for responses without explicit caching headers (default: TimeSpan.MinValue, meaning responses without caching headers are not cached)
  • CompressionThreshold: Minimum content size in bytes to enable compression (default: 1024 bytes). Set to 0 or negative value to disable compression
  • CompressibleContentTypes: Content types eligible for compression (default: text/*, application/json, application/json+*, application/xml, application/javascript, image/svg+xml)
  • CacheableContentTypes: Content types eligible for caching (default: text/*, application/json, application/json+*, application/xml, application/javascript, application/xhtml+xml, image/*)
  • ContentKeyPrefix: Prefix for content cache keys (default: "httpcache:content:"). Content is stored separately from metadata to avoid Base64 encoding overhead
  • IncludeDiagnosticHeaders: Whether to include diagnostic headers (X-Cache-Diagnostic, etc.) in responses (default: false)

Metrics

The handler emits the following counters via System.Diagnostics.Metrics under the meter named DamianH.HttpHybridCacheHandler:

Counter Description
cache.hits Number of cache hits (fresh, revalidated, stale-while-revalidate, stale-if-error)
cache.misses Number of cache misses (including cache errors and failed revalidations)
cache.stale Number of stale cache entries served (stale-while-revalidate or stale-if-error)
cache.size_exceeded Number of responses that exceeded MaxCacheableContentSize and were not cached

All counters include the following tags (following OpenTelemetry semantic conventions):

Tag Description Example
http.request.method HTTP method GET, HEAD
url.scheme URL scheme http, https
server.address Server hostname api.example.com
server.port Server port 443

Cache Behavior

Diagnostic Headers

When IncludeDiagnosticHeaders is enabled in options, the handler adds diagnostic information to responses:

  • X-Cache-Diagnostic: Indicates cache behavior for the request
    • HIT-FRESH: Served from cache, content is fresh
    • HIT-REVALIDATED: Served from cache after successful 304 revalidation
    • HIT-STALE-WHILE-REVALIDATE: Served stale while background revalidation occurs
    • HIT-STALE-IF-ERROR: Served stale due to backend error
    • HIT-ONLY-IF-CACHED: Served from cache with only-if-cached directive
    • MISS: Not in cache, fetched from backend
    • MISS-REVALIDATED: Cache entry was stale and resource changed
    • MISS-CACHE-ERROR: Cache operation failed, bypassed
    • MISS-ONLY-IF-CACHED: Not in cache with only-if-cached directive (504 Gateway Timeout)
    • BYPASS-METHOD: Request method not cacheable (POST, PUT, etc.)
    • BYPASS-NO-STORE: Request has no-store directive
  • X-Cache-Age: Age of cached content in seconds (only for cache hits)
  • X-Cache-MaxAge: Maximum age of cached content in seconds (only for cache hits)
  • X-Cache-Compressed: "true" if content was stored compressed (only for cache hits)

Example:

var options = new HttpHybridCacheHandlerOptions
{
    IncludeDiagnosticHeaders = true
};

Cacheable Responses

Only GET and HEAD requests are cached. Responses are cached when:

  • Status code is 200 OK
  • Cache-Control allows caching (not no-store, not no-cache without validation)
  • Content size is within MaxContentSize limit

Cache Key Generation

Cache keys are generated from:

  • HTTP method
  • Request URI
  • Vary header values from the response

Conditional Requests

When serving stale content, the handler automatically adds:

  • If-None-Match header with cached ETag
  • If-Modified-Since header with cached Last-Modified date

If the server responds with 304 Not Modified, the cached response is refreshed and served.

Samples

See the /samples directory for complete examples:

  • HttpClientFactorySample: Integration with IHttpClientFactory
  • YarpCachingProxySample: Building a caching reverse proxy with YARP
  • FusionCacheSample: Using FusionCache via its HybridCache adapter for enhanced caching features
  • FileDistributedCacheSample: File-based L2 cache with HttpHybridCacheHandler

FileDistributedCache Overview

A file-based IDistributedCache (and IBufferDistributedCache) implementation for .NET. Stores cache entries as individual files on the local filesystem — no external infrastructure required.

When to use FileDistributedCache vs Redis/SQL Server

Scenario FileDistributedCache Redis / SQL Server
Desktop apps (WPF, WinForms, MAUI) ✅ Ideal — local disk, no infrastructure Overkill
Mobile apps (MAUI, Xamarin) ✅ Ideal — device-local storage Not available
CLI tools and background agents ✅ Simple, self-contained Overkill
Single-instance services ✅ Good — persistent L2 with no dependencies Either works
Microservices / SOA (multi-instance) ❌ Not shared across instances ✅ Use Redis / SQL Server
Scaled-out web apps (load-balanced) ❌ Each instance has its own cache ✅ Use a shared cache

FileDistributedCache is the L2 backend for HybridCache when you need persistence across process restarts but don't have (or don't want) external cache infrastructure. For multi-instance services that need a shared cache, use Redis, SQL Server, or another distributed backend.

Key characteristics:

  • Zero infrastructure — no Redis, SQL Server, or other external dependencies
  • Persistent across restarts — cache survives process recycling
  • AOT compatible — fully trimming/AOT safe
  • IBufferDistributedCache support — implements the buffer-based interface for efficient HybridCache L2 integration (avoids byte[] allocations)
  • Background eviction — configurable periodic cleanup of expired entries, with optional soft limits on entry count and total size
  • Sliding expiration — last-access timestamps are updated on read for sliding window support
  • Concurrency safe — file-level locking with retry logic for concurrent access on Windows

FileDistributedCache Installation

dotnet add package DamianH.FileDistributedCache

FileDistributedCache Quick Start

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddFileDistributedCache(options =>
{
    options.CacheDirectory = Path.Combine(AppContext.BaseDirectory, "my-cache");
    options.MaxEntries = 10_000;
    options.MaxTotalSize = 500 * 1024 * 1024; // 500 MB
    options.EvictionInterval = TimeSpan.FromMinutes(5);
    options.DefaultAbsoluteExpiration = TimeSpan.FromHours(1);
});

var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<IDistributedCache>();

// Set a value
await cache.SetStringAsync("key", "value", new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
    SlidingExpiration = TimeSpan.FromMinutes(10)
});

// Get a value
var value = await cache.GetStringAsync("key");

FileDistributedCache Configuration Options

Option Type Default Description
CacheDirectory string {TempPath}/DamianH.FileDistributedCache Directory where cache files are stored
MaxEntries int? null (unlimited) Soft limit on number of cache entries. Oldest by last access are evicted when exceeded
MaxTotalSize long? null (unlimited) Soft limit on total cached data size in bytes. Oldest by last access are evicted when exceeded
EvictionInterval TimeSpan 5 minutes How frequently the background eviction scan runs
DefaultSlidingExpiration TimeSpan? null Default sliding expiration applied when an entry is stored without one
DefaultAbsoluteExpiration TimeSpan? null Default absolute expiration (relative to now) applied when an entry has no explicit expiration

Using with HttpHybridCacheHandler

FileDistributedCache is designed as a drop-in L2 backend for .NET's HybridCache. When both packages are registered, HybridCache automatically discovers the IDistributedCache / IBufferDistributedCache service and uses it for L2 storage:

var builder = Host.CreateApplicationBuilder(args);

// Register file-based L2 cache — HybridCache picks it up automatically
builder.Services.AddFileDistributedCache(options =>
{
    options.CacheDirectory = Path.Combine(AppContext.BaseDirectory, "http-cache");
    options.DefaultAbsoluteExpiration = TimeSpan.FromHours(1);
});

// Register the HTTP caching handler
builder.Services.AddHttpHybridCacheHandler(options =>
{
    options.FallbackCacheDuration = TimeSpan.FromMinutes(5);
});

// Wire up an HttpClient with caching
builder.Services
    .AddHttpClient("CachedClient")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        AutomaticDecompression = DecompressionMethods.All,
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    })
    .AddHttpMessageHandler(sp => sp.GetRequiredService<HttpHybridCacheHandler>());

var host = builder.Build();
var client = host.Services.GetRequiredService<IHttpClientFactory>().CreateClient("CachedClient");

// First request: fetched from network, stored in L1 (memory) + L2 (file)
// Subsequent requests: served from L1 or L2 — even across process restarts
var response = await client.GetAsync("https://api.example.com/data");

See samples/FileDistributedCacheSample for a complete working example.

Performance & Memory

The handler is designed for high-performance scenarios with several key optimizations:

Content/Metadata Separation Architecture

Eliminates Base64 overhead in distributed cache:

  • Metadata (small, ~1-2KB): Status code, headers, timestamps → Stored as JSON
  • Content (large, variable): Response body → Stored as raw byte[]
    • No Base64 encoding = 33% size savings
    • Content deduplication via SHA256 hash
    • Same content shared across cache entries (different Vary headers)

Trade-offs:

  • Two cache lookups (metadata + content) vs one lookup
  • Acceptable: L1 (memory) cache makes second lookup very fast (~microseconds)
  • Benefit: Zero Base64 overhead on all cached content

Memory Efficiency

  • Stampede Prevention (via HybridCache.GetOrCreateAsync): Multiple concurrent requests for the same resource are automatically collapsed into a single backend request
  • Automatic Deduplication: Only one request hits the backend while others await the cached result
  • Built-in HybridCache feature - no additional configuration needed

Efficient Caching

  • L1/L2 Strategy: Fast in-memory (L1) + optional distributed (L2) via HybridCache
  • Size Limits: Configurable per-item limits (default: 10MB) prevent memory issues
  • Conditional Requests: ETags and Last-Modified enable efficient 304 responses

Benchmark Results

See /benchmarks for comprehensive memory allocation benchmarks:

Response Size Allocations Gen2 (LOH) Notes
1-10KB ~10-20 KB 0 No LOH, optimal
10-85KB ~20-100 KB 0 No LOH, good
>85KB ~100KB+ >0 LOH expected, acceptable for reliability

Run benchmarks: cd benchmarks && .\run-memory-tests.ps1

Benchmarks

Run benchmarks to measure performance:

dotnet run --project benchmarks/Benchmarks.csproj -c Release

Contributing

Bug reports should be accompanied by a reproducible test case in a pull request.

About

Caching HttpHandler based on RFC 9111 that uses HybridCache

Topics

Resources

License

Stars

Watchers

Forks

Contributors