Caching

The gateway response cache for a capability — the cache block, scope, ttl, vary_by, invalidate_on, and what X-Cache tells a caller.

The gateway can cache a capability's responses for you. It is opt-in per capability, declared in the contract rather than configured anywhere, and absent by default: no cache block means the capability is never cached.

Set it only on read-safe capabilities. Writes must omit it.

manifest.capabilities.json
"cache": {
  "enabled": true,
  "scope": "tenant",
  "ttl": "300s",
  "methods": ["GET"],
  "vary_by": { "query": "*" },
  "invalidate_on": ["markets"]
}
FieldDescription
enabledRequired. Master switch. false means never cached.
scopeprincipal (default) — one entry per caller identity; never leaks one caller's data to another. tenant — one entry shared by every caller of a tenant. Use tenant only for tenant-global reference data such as markets, currencies, locales or tax classes.
ttlRequired when enabled. Hard staleness floor, e.g. "300s".
methodsGET (default) and/or POST. POST only for read-like capabilities whose body is the query — then set vary_by.body: true. PUT/PATCH/DELETE are never cacheable.
vary_byWhat makes the response differ: query (a whitelist array or "*"), headers (lower-cased names), body (boolean, for POST). Anything not listed is ignored, which improves the hit rate.
invalidate_onEntities whose change drops this capability's cached entries for the tenant. A bare name is an entity in this app; {app}.{entity} for a cross-app dependency.
cache_statusStatus codes whose responses may be cached. Default [200].

scope is the one to get right

scope decides who shares a cache entry, and getting it wrong is the only way this feature can hurt somebody.

principal (the default) gives one entry per caller identity. Two operators calling the same list get their own entries, so a response shaped by who is asking — a permission-filtered list, a personalised price — cannot reach the wrong caller.

tenant gives one entry shared by every caller of a tenant. It is the higher hit rate and the right answer for data that genuinely does not depend on who asked: the tenant's markets, its currencies, its locales, its tax classes.

The test is one question: could two callers in this tenant legitimately get different bodies from this route? If yes — because of permissions, scope context, or anything derived from the caller — it is principal. When in doubt, leave it out; the default is the safe one.

vary_by decides the key

Only what you list in vary_by is part of the cache key. Everything else is ignored, which is what makes the hit rate good and also what makes an omission a correctness bug.

manifest.capabilities.json
"vary_by": { "query": ["status", "limit", "offset"] }
  • query — an array of parameter names, or "*" for every query parameter. A named whitelist is better: an ignored tracking parameter no longer splits your cache into a thousand entries.
  • headers — lower-cased header names whose value changes the response. Add a header here if and only if your handler reads it.
  • bodytrue for a cacheable POST whose body is the query.

The rule: anything your handler reads must be in vary_by or in the scope. A paged list that does not vary by offset serves page one for every page — the same failure as an undeclared pagination parameter, from the other direction.

invalidate_on keeps it honest

A ttl alone means a merchant's change is invisible for up to that long. invalidate_on names the entities whose change drops the cached entries for that tenant immediately:

manifest.capabilities.json
"invalidate_on": ["device_serials", "markets.markets"]

A bare name is an entity in your own app. {app}.{entity} is another app's entity you depend on — useful when your response is derived from data you do not own.

So ttl is the backstop and invalidate_on is the mechanism. A read whose underlying entity you can name should list it; then you can afford a long ttl without a merchant wondering why the Cockpit still shows yesterday's value.

What a caller sees

Callers see X-Cache: HIT or MISS on a cacheable capability. That header is how a partner tells a fast response from a cached one, and how you confirm a policy is doing anything at all:

Request
curl -i "https://api.revenexx.com/v1/markets" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."

Two calls in a row should give MISS then HIT. If the second is still MISS, something in the request is varying that you did not intend — a header, a query parameter, or a principal scope you meant to be tenant.

The rest of the response headers are in API usage.

When to bother

Cache a capability when it is read-heavy, its answer is the same for many requests, and it is expensive enough to matter — reference data, a resolved configuration, an aggregate over many rows.

Do not cache a capability whose answer a caller acts on immediately as if it were current: stock availability, a lock, a balance. A stale orderable: true is worse than a slow orderable: false.

And never set a cache block on a write. PUT, PATCH and DELETE are not cacheable at all; a cached POST is only ever correct when the POST is a read whose body happens to be the query.

Next steps

Was this page helpful?