Response Caching
In-memory, capacity-bounded response cache middleware with TTL, Vary-header support, and manual invalidation.
CacheLayer is a middleware that short-circuits the inner application for cacheable GET responses, serving them directly from an in-memory store until their TTL elapses. It lives in src/cache/mod.rs and implements the standard Middleware trait, so it composes with every application variant.
Basic setup
use rust_web_server::app::App;
use rust_web_server::cache::CacheLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(CacheLayer::memory(1000).ttl(60));This caches up to 1 000 GET responses for 60 seconds each.
Constructor and builder methods
CacheLayer::memory(capacity)
Creates a new in-memory cache bounded to capacity entries. The default TTL is 60 seconds.
let layer = CacheLayer::memory(500);CacheLayer::disk(dir, max_bytes)
Keeps entries in files under dir (created if missing), bounded to max_bytes in total, oldest dropped first (since 18.11.0). Entries survive a restart: when the layer is built it indexes the directory and removes expired, unreadable and half-written files. Everything else (what is cached, for how long, Age, Vary) is the same as the memory cache.
let layer = CacheLayer::disk("/var/cache/rws/api", 512 * 1024 * 1024)?.ttl(600);- One file per entry, named by a hash of the cache key; it is written to a temporary file and renamed, so a reader never sees a half-written entry.
- Reads and writes are file I/O, so a disk-cached route runs on the blocking pool rather than the event loop. Use it for responses that are expensive to produce, not for ones a memory cache would serve in microseconds.
- A disk error (full disk, permissions) is logged and the response is simply not cached.
- In a container, put
diron a writable volume (the image's/srvis read-only).
.ttl(secs)
The longest an entry is kept. A response can ask for less with Cache-Control: s-maxage (meant for shared caches like this one), else max-age, is used when it's shorter than the ttl; a response that says neither is kept for the ttl. max-age=0 isn't stored at all.
let layer = CacheLayer::memory(500).ttl(300); // 5 minutes at most| Response says | Kept for (with ttl(300)) |
|---|---|
| nothing | 300 s |
Cache-Control: max-age=60 |
60 s |
Cache-Control: max-age=60, s-maxage=120 |
120 s |
Cache-Control: max-age=3600 |
300 s |
Cache-Control: max-age=0 |
not stored |
Since 18.11.0. Before, every cacheable response was kept for the full ttl whatever its max-age.
.vary_by_header(name)
Includes a request header in the cache key so that different values of that header produce separate cache entries. The name match is case-insensitive. Chain multiple calls to vary by more than one header.
let layer = CacheLayer::memory(500)
.vary_by_header("Accept-Language")
.vary_by_header("Accept");Without .vary_by_header, all requests to the same URI share a single cache entry regardless of any request headers.
What is cached
| Condition | Cached? |
|---|---|
| Method is GET | Yes (if other conditions pass) |
| Method is POST, PUT, DELETE, etc. | No — bypasses cache entirely |
| Response status 2xx (200, 201, 203, 204, 206, …) | Yes |
| Response status 3xx, 4xx, 5xx | No |
Response Cache-Control: no-store |
No |
Response Cache-Control: private |
No |
Response Cache-Control: no-cache (static files by default) |
No — it may only be reused after revalidation, which this cache doesn't do |
Response Vary names a request header that isn't in the key |
Not stored — unless it changed nothing: Origin without Access-Control-Allow-Origin, or Accept-Encoding with an unencoded body. Vary: * is never stored |
Request with Authorization, Cookie or Range |
No — bypasses cache entirely (the response may be per user or partial; since 17.113.1) |
Response with Set-Cookie |
No (since 17.113.1) |
Streamed response (stream_file / stream_pipe) |
No — the body isn't held in memory (since 17.113.1) |
Request Cache-Control: no-cache |
Bypass hit, call handler, store fresh response |
Cache key
The cache key is the request URI combined with the values of every header named in .vary_by_header(). Two requests to the same path but with different Accept-Language values produce separate entries when vary_by_header("Accept-Language") is configured.
Age header
On cache hits, the response receives an Age header indicating how many seconds have elapsed since the entry was inserted:
Age: 42If the response already carries an Age header, its value is replaced.
Eviction
A disk cache evicts by total size instead: expired entries first, then the oldest, until the files fit in max_bytes; an entry bigger than max_bytes on its own isn't stored.
When the memory store is at capacity and a new entry must be inserted, the oldest entry (by insertion time) is evicted. Expired entries are purged before every insert, so a full store of expired entries makes room before falling back to oldest-first eviction.
Cache statistics
CacheLayer is cheaply Clone-able — all clones share the same backing store. Keep a handle to query statistics or invalidate the cache while the other clone is used as middleware:
let cache = CacheLayer::memory(1000).ttl(60);
let cache_handle = cache.clone(); // shared reference
let app = App::new().wrap(cache);
// Later, from a handler or admin endpoint:
let hits = cache_handle.hits(); // u64
let misses = cache_handle.misses(); // u64
let size = cache_handle.size(); // current entry countcache.clear()
Evicts all entries immediately:
cache_handle.clear();Purging
purge removes part of a cache (memory or disk) and returns how many entries went. A target matches the request path and query as cached, every Vary variant included:
use rust_web_server::cache::Purge;
cache_handle.purge(&Purge::Exact("/products/42".into()));
cache_handle.purge(&Purge::Prefix("/products/".into()));
cache_handle.purge(&Purge::All); // same as clear()HTTP PURGE
.purge_token(token) makes the layer answer the PURGE method itself, nginx/Varnish style (config file: purge_token_env):
curl -X PURGE -H "Authorization: Bearer $TOKEN" https://example.com/products/42
curl -X PURGE -H "Authorization: Bearer $TOKEN" 'https://example.com/products/*'
# {"purged":3}A trailing * purges a prefix. A wrong or missing token gets 401; the request never reaches the application. Without a token set, PURGE passes through to the application like any other method. rws accepts PURGE as a request method since 18.11.0 (before, it was a 400).
Admin API and MCP
Named caches go in cache::registry: the binary registers its own as app, and every config route's cache as route:<name> (rebuilt on each reload). The admin API lists them at GET /admin/api/v1/caches and purges with DELETE /admin/api/v1/caches/<name>?path=… or ?prefix=… (see Admin API); the admin UI has a purge field on its Controls page. The cache_clear MCP tool takes the same optional path / prefix. Library apps call cache::registry::register(name, layer.clone()) to add theirs.
Full example
use rust_web_server::app::App;
use rust_web_server::cache::CacheLayer;
use rust_web_server::core::New;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
let cache = CacheLayer::memory(200)
.ttl(120)
.vary_by_header("Accept-Language");
let cache_handle = cache.clone();
let app = App::with_state(cache_handle)
.get("/api/products", |_req, _params, _conn, cache| {
// This response will be cached for 120 s per Accept-Language value.
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
})
.get("/admin/cache/stats", |_req, _params, _conn, cache| {
let body = format!(
"hits={} misses={} size={}",
cache.hits(), cache.misses(), cache.size()
);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
})
.wrap(cache.clone()); // middleware layer applied outermost.wrap() layers are applied in push order — the first .wrap() call is the outermost layer. Place CacheLayer last (innermost wrap position) if you want rate limiting or auth to run before the cache is consulted.