Cookbook / Requests and responses

HTTP caching and ETags

Answer 304 Not Modified, set Cache-Control, and skip the expensive work when the client already has the data.

Caching headers save bandwidth and, done in the handler, save the work too. The ConditionalGet middleware gives every successful GET an ETag and answers If-None-Match with 304. For expensive endpoints, check freshness yourself, with a cheap version number, before doing the work.

src/main.rs
use axeon::cache::{CacheControl, ConditionalGet, ETag};
use axeon::{Json, Response, Server, ServerError};
use axeon::json;
use std::time::Duration;

fn main() {
    let mut app = Server::new();

    // Weak ETags from response bodies, plus a default Cache-Control.
    app.middleware(ConditionalGet::new().cache_control(CacheControl::revalidate()));

    // Cheap and automatic: the handler runs, but a matching request gets 304.
    app.get("/v1/categories", |_req| async { Json(vec!["lighting", "furniture"]) });

    // Skip the work: compare a version number before building the response.
    app.get("/v1/products/:id", |req| async move {
        let id: i64 = req.param("id")?;
        let version = 42; // e.g. SELECT version FROM products WHERE id = $1
        let etag = ETag::strong(format!("p{}-v{}", id, version));
        if req.is_fresh(Some(&etag), None) {
            return Ok(Response::not_modified(&etag));
        }
        let body = json!({ "id": id, "version": version }); // the expensive part
        Ok::<_, ServerError>(
            Response::json(&body)
                .with_etag(&etag)
                .with_cache_control(CacheControl::new().private().max_age(Duration::from_secs(60))),
        )
    });

    app.listen("127.0.0.1:3000").expect("server failed to start");
}
Terminal
curl -si http://127.0.0.1:3000/v1/categories          # ETag: W/"..."
curl -si http://127.0.0.1:3000/v1/categories -H 'if-none-match: W/"<value from above>"'   # 304, no body
curl -si http://127.0.0.1:3000/v1/products/7           # ETag: "p7-v42"
curl -si http://127.0.0.1:3000/v1/products/7 -H 'if-none-match: "p7-v42"'                 # 304

Notes#

  • Use CacheControl::revalidate() (no-cache) for data that must never be stale: caches keep a copy but check it with the ETag before every use. Use .public().max_age(..) for data every user may share, and .private() for personal data. Use CacheControl::never() (no-store) for sensitive responses.
  • A strong ETag promises identical bytes; a weak one only equivalent content. Body hashes from ConditionalGet are weak, because compression may change the bytes.
  • Add ConditionalGet after Compression so tags describe the uncompressed body.
  • If-None-Match wins over If-Modified-Since, as the spec says. For the date form, set .with_last_modified(time) and pass the time to req.is_fresh.
  • Handlers that set their own ETag or Cache-Control keep them: the middleware only fills in what is missing. Streamed responses are left alone.