Design Overview

Why always forward?

Conventional caching proxies serve from cache when the entry is fresh, skipping the origin entirely. This is the whole point of a cache — but it means the origin’s access control is bypassed for cached responses. If a user’s credentials are revoked, they can still receive cached content until it expires.

Relaycache’s security model is simpler and stricter:

The origin server is the sole authority on both content freshness and access control. Relaycache never serves a response body without the origin first confirming the request is valid.

Every request — regardless of cache state — is forwarded to the origin with all original headers intact (including Authorization, cookies, etc.). The cache is a bandwidth-saving mechanism only.

Request lifecycle

                    ┌─────────────────────────────────────┐
                    │              Relaycache                 │
                    │                                      │
Client request ────►│  1. Look up cache key               │
(with auth)         │  2. Build forwarded request:        │
                    │     - all client headers             │
                    │     - merge validators from cache    │
                    │     - strip Range if body cached     │
                    │  3. Forward to origin               │──► Origin
                    │                                      │◄── 200 / 304 / 4xx
                    │  4a. 200: store blob, return 200    │
                    │  4b. 304 + proxy hit: return 200    │──► Client
                    │       or 206 (if range requested)   │
                    │  4c. 304 + client hit: return 304   │
                    │  4d. anything else: pass through    │
                    └─────────────────────────────────────┘

Headers added by Relaycache

On forwarded requests (to origin)

Header

Value

Purpose

Via

1.1 relaycache

RFC 7230 §5.7.1 — identifies proxy in chain

On responses (to client)

Header

Value

Purpose

Via

1.1 relaycache

RFC 7230 §5.7.1 — identifies proxy in chain

X-Cache

HIT or MISS

Whether body came from cache or origin

X-Cache-Key

the cache key string

Debugging — shows which key was used including Vary dimensions

What is and is not cached

Condition

Cached?

Reason

GET + 200 OK

Standard cacheable response

GET + 200 OK + Vary: *

RFC 7234: must not cache

GET + 200 OK + Vary: Authorization

User-personalised; would require per-credential storage

GET + 200 OK + body > --max-cacheable-size

Too large

Any non-GET method

Only GET bodies are idempotent to cache

Any non-200 status

Error responses, redirects, etc. pass through

Module structure

src/
  main.rs      Entry point: wires config → store → proxy → listener
  config.rs    CLI/env config parsing (clap, humantime, size parsing)
  headers.rs   All HTTP header logic (pure functions, well-tested)
  proxy.rs     Request handler: cache lookup → forward → store/serve
  store.rs     CacheStore: moka index + SQLite DB + blob files

Key invariants

  1. Every request reaches the upstream. Never serve a response body without a round-trip to the upstream first.

  2. Auth headers are never stored. store::safe_response_headers strips Authorization, Set-Cookie, WWW-Authenticate, and Proxy-* headers. Add a unit test verifying this if UNSAFE_RESPONSE_HEADERS ever changes.

  3. Vary: Authorization → never cache. Enforced in both headers::vary_prevents_caching and store::insert.

  4. Blob files are immutable. Written once (content-addressed); write_blob skips writing if the file already exists.

  5. WAL checkpoint on clean shutdown. store::DbOp::Shutdown triggers PRAGMA wal_checkpoint(TRUNCATE). Do not exit without sending this.