Caching a REST API
GraphPilot caches and protects a plain HTTP API without reading a schema. Your origin decides what may be cached and for how long, in the headers it already knows, and names what a write changes in one more header. This guide takes a REST API from its first cached response to purging on writes. If you have not deployed a service yet, do the HTTP quickstart first; this page continues from it.
The running example is a catalog API behind https://catalog-api.k7m2qp.graphpilot.cloud (k7m2qp stands for your organization’s hostname label, which the portal shows):
GET /products/42 reads a product, PUT /products/42 changes it, GET /products?category=shoes
lists a category, and POST /search takes a query in its body.
What you need
Section titled “What you need”- A service whose origin is your API, and the
gpilotCLI logged in. - The ability to set response headers on your origin.
- A
gpilot.toml, and a deployed service.
A REST API needs no GraphQL schema: without a schema file, gpilot deploy deploys the config
alone, as a plain HTTP service. The plain HTTP requests of a service deployed with a schema get
everything on this page too.
1. Say how long a response may be cached
Section titled “1. Say how long a response may be cached”GraphPilot never decides on its own that a response is reusable. Your origin says so with
Cache-Control:
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: max-age=60, s-maxage=300, stale-while-revalidate=30s-maxage is the lifetime at the edge, max-age the one a browser keeps, and
stale-while-revalidate lets the edge serve an expired copy while it fetches a new one. A
response with no Cache-Control is not stored. no-store and no-cache are never stored;
private is stored only with a private
policy. Which directives are read, and which
headers are ignored (Expires, Surrogate-Control), is on lifetime from the origin
header.
An entry is keyed by the request method and the path, query string included, so
/products?category=shoes and /products?category=bags are two entries. The hostname is not part
of the key, so the service hostname and a custom domain share one entry per path. If your origin
answers the same path differently per hostname, set
cache.http_key.host = "include" to key by the full URL.
A request that carries a body is keyed by a hash of its body as well, so two POST /search requests with
different queries never share an entry.
A 4xx or 5xx response is not stored by default, even with a storable Cache-Control. If
your origin marks a 404 cacheable on purpose, for a product that does not exist, list the status
in cache.policy.store_error_statuses; see when a response is
cached.
The body your client receives is the one your origin sent, byte for byte, on a hit and on a miss.
2. Partition what differs per caller
Section titled “2. Partition what differs per caller”A shared lifetime serves one answer to everyone who sends the same request. Where the answer
depends on who asks, derive the difference in gpilot.toml:
[auth.providers.idp]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://idp.example.com/.well-known/jwks.json"allowed_issuers = ["https://idp.example.com/"]allowed_audiences = ["my-api"]
[cache.policy]private = 'auth:idp.sub'bypass = 'query:preview'
[[cache.policy.vary]]name = "x-org"value = 'auth:idp.org_id'
[[cache.policy.vary]]name = "x-locale"value = "lower(split(split(split(header:accept-language, ',', 0), ';', 0), '-', 0)) || 'en'"pass_to_origin = "plain"auth:reads a verified claim. Use it for anything that is a security boundary: the organization here, the user inprivate.header:reads a request header the caller chose. Fine for a preference such as the language, never for a boundary. The rule reducesAccept-Languageto its primary language and sends it to your origin asx-locale, which your origin renders from; see caching per language.query:reads a URL parameter. The query string is already part of the key, so on a vary rule it adds nothing; it is useful inbypass, which sends?preview=1requests past the cache.
Every vary rule partitions every entry, so configure only those whose value shapes a response.
A request from a signed-in caller is shared only when your origin says so with public or
s-maxage; see authenticated requests. Mark a
response for one person Cache-Control: private, max-age=60, and it is stored per sub. A full
walk-through with an organization and a role is in partitioning your
cache.
3. Tag reads with keys you choose
Section titled “3. Tag reads with keys you choose”Name what a response depends on in a Surrogate-Key header:
# The response to GET /products/42HTTP/1.1 200 OKCache-Control: s-maxage=300Surrogate-Key: product-42 category-shoes# The response to GET /products?category=shoesHTTP/1.1 200 OKCache-Control: s-maxage=300Surrogate-Key: category-shoesOn the response to a GET or HEAD, the header tags the entry. GraphPilot removes it before the
response reaches your client. Keys are separated by spaces or commas, and anything outside
printable ASCII is folded (Müller becomes Muller); the rules are on the Surrogate-Key header.
4. Purge on writes
Section titled “4. Purge on writes”On the response to a POST, PUT, PATCH or DELETE, the same header purges instead of
tagging. Answer the write with the keys it changed:
# The response to PUT /products/42HTTP/1.1 200 OKSurrogate-Key: product-42 category-shoesEvery cached response tagged product-42 or category-shoes is removed at every edge
location, and the next GET for each goes to your origin. The purge is recorded in the
service’s purge history.
For a change that does not pass through GraphPilot (an import, an admin tool, a database migration), purge from that code or a runbook:
gpilot purge product-42 # by a key your origin namedgpilot purge ck:GET:/products/42 # one response, by its cache keygpilot purge --all # everythingEach command asks for confirmation first. From a script or CI job, add --yes: without a
terminal to ask on, the command fails instead.
There is no purge by URL: ck:<METHOD>:<path>, with the query string but without scheme and
hostname, is how you address one response (the full URL, if the service sets host = "include"). The API offers the
same, plus purging the entry one logged request read; see purging through the API and
CLI.
5. Decide what happens when your origin fails
Section titled “5. Decide what happens when your origin fails”Retries are off until you switch them on under [origin.retry].
For a plain HTTP request, GraphPilot then repeats only GET, HEAD and OPTIONS, and sends a
write once unless you set retry_unsafe_methods = true for an origin whose writes are idempotent. Which failures count, the backoff and the time budget are on
retries.
Check that it works
Section titled “Check that it works”Read, write, read again:
H="https://catalog-api.k7m2qp.graphpilot.cloud"curl -sS -D- -o/dev/null "$H/products/42" | grep -i '^gp-cache:' # MISScurl -sS -D- -o/dev/null "$H/products/42" | grep -i '^gp-cache:' # HITcurl -sS -o/dev/null -X PUT "$H/products/42" \ -H 'content-type: application/json' -d '{"price":99}'curl -sS -D- -o/dev/null "$H/products/42" | grep -i '^gp-cache:' # MISSThe third read is a MISS because the PUT’s response purged product-42.
To see the keys an
entry carries, switch on response.surrogate_key_header and read gp-surrogate-key; see
debugging surrogate keys. A PASS carries
gp-cache-reason with the rule that refused; why is nothing
cached? goes through them.
Pitfalls
Section titled “Pitfalls”- A cookie on a read disables caching for it. A framework that refreshes the session on every
response sends
Set-Cookie, and nothing is stored (CACHE_SKIPPED_SET_COOKIE). See what refuses storing. Authorizationon every request. Withoutpublicors-maxageon the response, it is filed per caller or not cached (CACHE_SKIPPED_CREDENTIALED_REQUEST).- Non-ASCII keys are folded, and folding is lossy.
MüllerandMullerbecome one key, so a purge of either clears both. Prefer identifiers to names in keys. Vary: *is never stored. Name the headers you vary on.OPTIONSis not cached by default, so CORS preflights reach your origin; seecache.policy.uncacheable_methods.- Keep REST routes outside a GraphQL path. If the service also has a schema, every path at
or below an entry of
request.graphql_paths(/graphqlby default), in any letter case, is read as GraphQL. APOST /graphql/healthwith a JSON body is refused with415andUNSUPPORTED_GRAPHQL_BODY; aGETwithout a body or GraphQL parameters still passes. Move such routes to their own path, or list the body’s kind inrequest.forward_unchecked_bodies. POST /__gp/purgeis reserved. GraphPilot answers aPOSTto exactly that path itself, on every hostname of the service, and never forwards it. Do not use it in your API.
What stays GraphQL-only
Section titled “What stays GraphQL-only”Everything above works without a schema. These features need a parsed GraphQL operation:
| Feature | For a plain HTTP API instead |
|---|---|
Lifetimes and scopes from @cacheControl | Cache- from your origin |
| Named scopes such as one entry per organization | A vary rule, which applies to every entry |
| Surrogate keys derived from the schema, selection set expansion | Surrogate- from your origin |
| Automatic purging of the objects a mutation returned | Surrogate- on the write’s response |
| Purge by query hash | Purge by key or by cache key |
| Persisted queries and trusted documents | Nothing; your paths are your allowlist |
| Limits on query depth, document size and selections; introspection block | The request body limit, which applies to both |
| Client profile rules for depth, size, selections and persisted queries | The other client profile rules, which apply to both |
The full matrix is on features and limitations.
Related
Section titled “Related”- Quickstart: HTTP
- Lifetime from the origin header
- Identifying callers
- The Surrogate-Key header
- Request signing, to make your origin accept only traffic that came through GraphPilot