Skip to content

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.

  • A service whose origin is your API, and the gpilot CLI 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.

GraphPilot never decides on its own that a response is reusable. Your origin says so with Cache-Control:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60, s-maxage=300, stale-while-revalidate=30

s-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.

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:

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 in private.
  • header: reads a request header the caller chose. Fine for a preference such as the language, never for a boundary. The rule reduces Accept-Language to its primary language and sends it to your origin as x-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 in bypass, which sends ?preview=1 requests 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.

Name what a response depends on in a Surrogate-Key header:

# The response to GET /products/42
HTTP/1.1 200 OK
Cache-Control: s-maxage=300
Surrogate-Key: product-42 category-shoes
# The response to GET /products?category=shoes
HTTP/1.1 200 OK
Cache-Control: s-maxage=300
Surrogate-Key: category-shoes

On 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.

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/42
HTTP/1.1 200 OK
Surrogate-Key: product-42 category-shoes

Every 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.

The write's response names the keys it changed in Surrogate-Key; every entry tagged with one of them is removed.

For a change that does not pass through GraphPilot (an import, an admin tool, a database migration), purge from that code or a runbook:

Terminal window
gpilot purge product-42 # by a key your origin named
gpilot purge ck:GET:/products/42 # one response, by its cache key
gpilot purge --all # everything

Each 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.

Read, write, read again:

Terminal window
H="https://catalog-api.k7m2qp.graphpilot.cloud"
curl -sS -D- -o/dev/null "$H/products/42" | grep -i '^gp-cache:' # MISS
curl -sS -D- -o/dev/null "$H/products/42" | grep -i '^gp-cache:' # HIT
curl -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:' # MISS

The 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.

  • 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.
  • Authorization on every request. Without public or s-maxage on the response, it is filed per caller or not cached (CACHE_SKIPPED_CREDENTIALED_REQUEST).
  • Non-ASCII keys are folded, and folding is lossy. Müller and Muller become 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.
  • OPTIONS is not cached by default, so CORS preflights reach your origin; see cache.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 (/graphql by default), in any letter case, is read as GraphQL. A POST /graphql/health with a JSON body is refused with 415 and UNSUPPORTED_GRAPHQL_BODY; a GET without a body or GraphQL parameters still passes. Move such routes to their own path, or list the body’s kind in request.forward_unchecked_bodies.
  • POST /__gp/purge is reserved. GraphPilot answers a POST to exactly that path itself, on every hostname of the service, and never forwards it. Do not use it in your API.

Everything above works without a schema. These features need a parsed GraphQL operation:

FeatureFor a plain HTTP API instead
Lifetimes and scopes from @cacheControlCache-Control from your origin
Named scopes such as one entry per organizationA vary rule, which applies to every entry
Surrogate keys derived from the schema, selection set expansionSurrogate-Key from your origin
Automatic purging of the objects a mutation returnedSurrogate-Key on the write’s response
Purge by query hashPurge by key or by cache key
Persisted queries and trusted documentsNothing; your paths are your allowlist
Limits on query depth, document size and selections; introspection blockThe request body limit, which applies to both
Client profile rules for depth, size, selections and persisted queriesThe other client profile rules, which apply to both

The full matrix is on features and limitations.