Skip to main content

proxy-cache

proxy-cache

Serving a cached response means looking up the cache before the upstream call and storing the fresh response after it — two moments a single graph node cannot span. So proxy-cache is a pair of nodes sharing one cache, linked by a required id:

  • a lookup node placed before upstream, which serves a cache hit straight to the client (short-circuiting the upstream call), and
  • a store node placed after upstream, which caches a fresh response for later hits.

Both nodes derive the cache key identically from the same cache_key template and the request, and share one namespace via id, so they always agree.

Configuration

KeyTypeDefaultDescription
phase (or role)string— (required)lookup (before upstream), store (after upstream), or purge (on a write route; see Invalidating on a write).
idstring— (required)Shared cache namespace; the lookup and store nodes of one pair must match.
cache_keyarray of string templates (or a single string)["$request_method", "$host", "$uri"]Components interpolated and joined to form the key. Both nodes must configure it identically.
cache_ttlinteger (seconds)300Freshness lifetime for stored entries.
cache_http_statusesarray[200, 301, 404]Response statuses eligible for caching. (The singular spelling cache_http_status is also accepted for config compatibility.)
cache_methodarray["GET", "HEAD"]Cacheable request methods; other methods bypass the cache in the lookup and store phases.
hide_cache_headersboolfalseStrip cache-control / expires from served cache hits.
policystringlocallocal (an in-memory, per-instance cache) or redis (a shared cache over a declared store). Both nodes of a pair must use the same policy and, for redis, the same store.
storestring— (required when policy: redis)Name of a declared stores: entry.
max_object_bytesinteger1048576Responses larger than this are served normally but never cached, in either backend.

Wiring

The lookup node goes before upstream; its hit port routes to client.in, so a hit delivers the cached response without ever calling the upstream. On a miss it passes through. The store node goes after upstream and caches the fresh response.

nodes:
- id: cache-lookup
type: proxy-cache
config: { phase: lookup, id: catalog, cache_key: ["$request_method", "$host", "$uri"], cache_ttl: 300 }
- id: upstream
type: upstream
config: { targets: [{ host: catalog, port: 8080 }] }
- id: cache-store
type: proxy-cache
config: { phase: store, id: catalog, cache_key: ["$request_method", "$host", "$uri"],
cache_ttl: 300, cache_http_statuses: [200, 301, 404] }
edges:
- { from: listener.out, to: cache-lookup.in }
- { from: cache-lookup.success, to: upstream.in }
- { from: cache-lookup.hit, to: client.in } # cache HIT → client
- { from: upstream.success, to: cache-store.in }
- { from: cache-store.success, to: client.in }
- { from: cache-store.hit, to: client.in } # store never hits, but the port is still mandatory wiring

Behavior

Requests whose method is not in cache_method bypass the cache in the lookup and store phases (pass through untouched).

The lookup node derives the key and queries the cache. On a hit it replaces context.response with the cached status, headers, and body, adds featherbit-cache-status: HIT (and strips cache-control/expires when hide_cache_headers is set), then exits through the hit port. On a miss it passes through to the upstream on success.

The store node caches the response when its status is in cache_http_statuses, using cache_ttl as the freshness lifetime, and marks the outgoing response featherbit-cache-status: MISS (it came from the upstream, not the cache).

With policy: local (the default) the cache is in-memory and per gateway instance; entries expire lazily on read, and the process-wide cache.max_entries setting in system.yaml (default 10000, shared by every policy: local node) bounds how many it holds — once full, the entries expiring soonest are evicted (gateway_cache_events_total{backend="local",event="eviction"}). With policy: redis, the pair shares one namespace in the named store, so multiple gateway instances — or multiple policies configured with the same store and cache_key — see each other's entries; max_entries does not apply, since the store bounds itself via its own maxmemory policy.

This cache fails open, unlike the rest of the system

Sessions and the store-* nodes fail closed: losing their store means losing correctness, so the request fails rather than proceeding as though an absent session said yes.

A cache is different in kind. Its only job is to save a trip to the upstream, so a backend it cannot reach is treated as a miss and the request is served normally. Turning a redis blip into a 503 on a route that was merely going faster would be a worse outage than the one it reports.

It is not silent: gateway_cache_events_total{backend="...",store="...",event="error"} counts every failed lookup or write, labelled by which store degraded (empty for policy: local), and a cache degraded to always-miss is otherwise invisible in every signal except the upstream's load. See Observability for the full label reference.

Both halves of a pair must agree on where they cache

The lookup and store nodes are linked only by their shared id. If they disagree about policy or store, the store half writes somewhere the lookup half never reads — the policy compiles, serves traffic, and returns a permanent 100% miss with no error anywhere. It simply looks like a cache that is never warm.

There is no configuration for which that is correct, so the compiler now rejects it and names both nodes.

A half with no counterpart is reported rather than rejected: a lookup with no store caches nothing, a store with no lookup is never read, and a purge with nothing to purge for is a no-op every time it fires — but all three are also what a policy looks like halfway through being built. They appear in the cache_pairs array of POST /api/policies/validate and the MCP validate_policy tool, alongside buffering.

Invalidating on a write

A third phase, purge, clears everything its pair has cached. Put it on the route that changes the resource, after the upstream:

- id: drop-cache
type: proxy-cache
config: { phase: purge, id: products, policy: redis, store: cache-store }

wired forward-write.success → drop-cache.in. Gating on the upstream's status is yours to decide — a condition on status before it, if only a 2xx should purge.

cache_method does not gate phase: purge — a purge acts on the pair's namespace, not on one request's cached representation, so it fires on POST/PUT/DELETE too (the default cache_method for lookup/store is only GET/HEAD).

A failed purge takes error, unlike a failed lookup. A lookup that cannot reach its backend becomes a miss, because a cache only saves latency. A purge is different: you asked for state to change, and continuing silently would leave the cache stale in exactly the case invalidation exists to fix.

A phase: purge node placed on an upstream's success path buffers that upstream's response (the same as phase: store) — because a failed purge can exit error, and an error response cannot be produced mid-stream once bytes have already gone out. Write responses are usually small, so this rarely matters in practice; POST /api/policies/validate reports it as a buffering reason if it does.

A policy: redis purge scans the whole store, not just the pair. It SCANs the store's entire keyspace incrementally, so its cost grows with the store's total key count, not with the number of entries the pair actually cached; UNLINK frees the matched keys' memory off-thread rather than blocking on it. Do not wire a purge to a high-rate write path on a large shared store. Entries written concurrently during a purge may survive it — invalidation here is best-effort under concurrent writes, not a snapshot.

A policy: local purge clears this instance only. No message reaches other instances. policy: redis purges are cluster-wide because the store is shared.

The purge half is held to the same agreement rule as the other two: it must use the same policy and store as its pair, or the compiler rejects the policy.

PortSpec is per node type, so a phase: purge node — like phase: store — must wire a hit port that never fires.

Ports

proxy-cache declares three output ports: success (a cache miss, a non-cacheable method, or a completed purge — the request continues), hit (the response was served from cache; wire straight to client), and error (taken only by a failed purge — a lookup or store backend outage degrades to a miss or a skipped write, not a routed error). success and hit are mandatory on all three phases — the policy compiler rejects any policy that leaves either unwired, even on the store and purge nodes where hit is never actually emitted. See Wiring above.

Errors

The lookup and store phases never fail at execution time: they always return through success or hit, so error is never taken for them — see the fail-open note above. The purge phase is the exception: a purge that cannot reach its backend exits error with CACHE_PURGE_FAILED, because silently continuing would leave the cache stale in exactly the situation invalidation exists to fix. See Invalidating on a write.