Skip to content

When a response is cached

Two questions decide what the edge cache does with a request: whether an entry may be served, and whether the origin’s answer may be stored. This page walks both, in the order GraphPilot evaluates them.

Every refusal below reports gp-cache: PASS and a stable code on the gp-cache-reason response header and in your logs. Those codes are listed with the cache statuses.

Reading: can this be served from the cache?

Section titled “Reading: can this be served from the cache?”

The lookup happens before anything is parsed. In order:

  1. Is this a WebSocket upgrade? A subscription handshake is never cacheable. PASS, CACHE_PASS_WEBSOCKET_UPGRADE.
  2. Is the request method one you never cache? cache.policy.uncacheable_methods lists them, and it holds OPTIONS unless you change it. PASS, CACHE_PASS_UNCACHEABLE_METHOD. See preflights and the methods you never cache.
  3. Does the matched client profile take this client off the cache? PASS, CACHE_PASS_CLIENT_CACHE_OFF. See client profiles.
  4. Is caching bypassed for this request? If your service configures cache.policy.bypass and the expression matches, the request goes to your origin and its response is not stored. PASS, CACHE_PASS_BYPASS_POLICY.
  5. Did the client ask for a response the origin has just confirmed? Only when cache.request_directives.no_cache is "honor": a request Cache-Control of no-cache or max-age=0 goes to your origin and its response is not stored. PASS, CACHE_PASS_REQUEST_NO_CACHE. Ignored by default; see the client’s own Cache-Control.
  6. Is there a cache key at all? PASS, CACHE_PASS_MISSING_CACHE_KEY. A GraphQL request whose body carries both a document and a persisted query hash that is not that document’s hash has none, so it is never looked up or stored; it still runs as before. An APQ retry that sends its own document’s hash keeps its key.
  7. Is edge caching withdrawn? In lean mode, after the plan’s request quota is used up, the cache stands aside entirely: no lookup, no write, and no gp-cache header. Invalidation keeps running, see automatic invalidation.
  8. Can the cache be reached? If the lookup fails, the request is served from the origin rather than failing. PASS, CACHE_PASS_READ_DISABLED.

If an entry matching the key exists and can be decoded, it is served, unless the matched client profile caps the age it accepts with max_age and the entry is older. Then this request goes to your origin and reports PASS with CACHE_SKIPPED_ENTRY_NOT_REPLACED (the log line CACHE_CLIENT_MAX_AGE_MISS records why the entry was declined). The origin’s answer is served without being stored, and the entry itself is unchanged and keeps serving other clients.

An entry that cannot be read is treated as absent: the answer from your origin is written over it and reports MISS, or PASS with CACHE_SKIPPED_ENTRY_NOT_REPLACED if that write is not possible. Otherwise:

  • HIT if it is still fresh.
  • STALE if it is past its freshness lifetime but inside a stale window. The stored response is returned immediately and the origin is asked for a fresh one in the background.

On a miss, the origin’s response runs through the following. The first rule that refuses ends it, and the response is still delivered to the client in full: only storing is refused.

With shielding, an edge location that got its answer from the shield checks one thing before all of these: a response the shield served STALE or PASS, or one whose stored form or age the edge location cannot read, is passed on and not stored there, CACHE_SKIPPED_SHIELD_RESPONSE_NOT_STORABLE. A response it does store lives only for the lifetime the shield’s copy has left, so an entry never outlives what your origin allowed across both locations. When nothing is left, it is not stored, CACHE_SKIPPED_SHIELD_ENTRY_EXHAUSTED.

A mutation or a subscription response is never written to the cache, and no header can opt back in. CACHE_SKIPPED_UNCACHEABLE_OPERATION.

A plain HTTP response, or the response to a body forwarded unchecked under request.forward_unchecked_bodies, is not refused here: nothing knows it to be a write. A GraphQL document that does not parse never gets this far: it is refused with 400 and INVALID_GRAPHQL_REQUEST before your origin is called. Any method except those in cache.policy.uncacheable_methods can be cached when the response says so.

Then the selection: a field your deployed schema does not declare has no hint and no scope GraphPilot could read, so nothing says who may share its value or for how long. Data for a query that selects such a field, or a fragment on such a type, at any depth, is not written, and no header can opt back in. CACHE_SKIPPED_UNRESOLVED_SELECTION. It usually means your origin serves a newer schema than the one you deployed to GraphPilot; see deploy the schema before the origin. __typename, __schema and __type never count.

Next, the request’s own Cache-Control: if it says no-store and cache.request_directives.no_store is "honor", which is the default, the response is not written. CACHE_SKIPPED_REQUEST_NO_STORE. Nothing a policy says outranks it, because it is a statement about the exchange rather than about the response. See the client’s own Cache-Control.

1. An error status is not stored unless you opt in

Section titled “1. An error status is not stored unless you opt in”

A response with a 4xx or 5xx status is not stored by default, even when it carries a storable Cache-Control or your schema gives it a lifetime. Many frameworks and hosting platforms attach a blanket Cache-Control to every response, errors included, and an error served from the cache repeats one failure to every caller until the entry expires. It reports PASS with gp-cache-reason: CACHE_SKIPPED_ERROR_STATUS. This runs before the refusals of step 2, so a 503 that also sets Set-Cookie reports CACHE_SKIPPED_ERROR_STATUS.

To store an error response your origin marked cacheable, such as a 404 for a resource that does not exist, list its status in cache.policy.store_error_statuses. A listed status goes on to the rules below, starting with step 2. This applies to plain HTTP and GraphQL alike; a GraphQL 200 that carries errors keeps its own rule.

gpilot.toml
[cache.policy]
store_error_statuses = [404, 410]

2. Refusals that depend only on what the response carries

Section titled “2. Refusals that depend only on what the response carries”

These stand ahead of both policy sources. Whatever made the response cacheable, it is not storable:

The response carriesCode
Set-Cookie, Set-Cookie2CACHE_SKIPPED_SET_COOKIE
WWW-Authenticate, Proxy-AuthenticateCACHE_SKIPPED_AUTHENTICATION_HEADER
Authentication-Info, Proxy-Authentication-InfoCACHE_SKIPPED_AUTHENTICATION_HEADER
Clear-Site-Data, Set-LoginCACHE_SKIPPED_AUTHENTICATION_HEADER
A GraphQL errors array, including partial data beside itCACHE_SKIPPED_GRAPHQL_ERRORS
A GraphQL response body GraphPilot cannot parse as JSONCACHE_SKIPPED_UNPARSEABLE_GRAPHQL_RESPONSE
Vary: *CACHE_SKIPPED_VARY_WILDCARD

Each header group states something about one caller’s session at one instant, which a cache entry would replay on every hit: a stored Set-Cookie re-asserts that cookie on the next caller, a stored Clear-Site-Data wipes their session. GraphQL errors describe the moment, not the data. Vary: * can never match a later request; see your origin’s Vary header.

A GraphQL response with a JSON content type is read before it is stored, to find errors and derive the surrogate keys a purge needs. When the body cannot be read, neither is possible: an entry made from it could hold an error or be out of reach of every keyed purge, so it is not stored. The client still receives the body exactly as your origin sent it, including any key fields selection set expansion added, because removing those needs the parse that failed. The common causes are a body that is not valid JSON, a body larger than 133 MiB, and an integer larger than 18446744073709551615, which is valid JSON that GraphPilot does not read. Return such numbers as strings (an ID or a custom scalar); most GraphQL clients need that anyway, since JavaScript loses integer precision far below that value. A plain HTTP body is never parsed, so this rule does not apply to it.

Presence alone is enough. Set-Cookie is checked per cookie: the log line names the cookie that blocked it, and a cookie on cache.cookies.allow does not count. The other headers in the table stop refusing once you name them in cache.headers.allow.

There is no exception for private, partitioned entries: a partition does not stop a caller being handed their own old cookie, which would revert a rotation or a sign-out on the next hit. GraphPilot refuses to store rather than stripping the header, so your client receives what your origin sent.

To get caching back, stop setting these headers on cacheable reads, for example by refreshing a session only on the requests that actually change it, or mark those responses Cache-Control: no-store so the intent is explicit.

What is left is the policy: how long the response may be kept, and who may be served it.

  • How long comes from your origin’s Cache-Control header if it states storage or freshness, otherwise from your schema’s @cacheControl, otherwise nowhere, and the response is not cached. See how long an entry lives for the precedence, from the origin header for the order the header is checked in, and from the schema.
  • For whom comes from private on the header, the scopes the query selected, your vary rules, and, for a request that carried a credential, whether the origin said the response may be shared. A response that cannot be placed is refused rather than shared. See who shares an entry and authenticated requests.

The refusals from this step:

CodeWhy
CACHE_SKIPPED_NO_STOREThe origin said no-store.
CACHE_SKIPPED_NO_CACHE_DIRECTIVEThe origin said no-cache.
CACHE_SKIPPED_NO_MAX_AGEThe deciding source stated no lifetime.
CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATORA private or named-scope response could not be partitioned for this request.
CACHE_SKIPPED_CREDENTIALED_REQUESTA signed-in caller’s response did not say it may be shared, and nothing identified the caller.
CACHE_SKIPPED_NO_CACHE_POLICYNeither your origin nor your schema stated a policy.

A response that reaches this step carrying no surrogate keys at all is not cached, because no purge could reach it: CACHE_SKIPPED_NO_SURROGATE_KEYS. This is rare; see the code’s entry.

Surrogate keys are compressed to fit a 16,000-byte budget. A response whose keys do not fit even in the most compact form is not cached, rather than cached with keys missing, so that a purge always reaches everything it should. CACHE_SKIPPED_SURROGATE_KEYS_TOO_LARGE, and see how many keys fit on one response.

A response that cannot be encoded, or that the cache refuses to write, reports CACHE_SKIPPED_SERIALIZATION_FAILED or CACHE_SKIPPED_INSERT_FAILED. Both are faults on GraphPilot’s side, not configuration problems, and like every refused store they report PASS.

The cache key is derived from the request before anything is parsed, which is why a hit costs no parsing.

For a GraphQL operation it is four parts, in this order, the last two only when present:

<route>#<sha256 of the query text>[#<operationName>][:<hash of the variables>]
  • The route is the path the request was sent to and its query string without the GraphQL parameters (query, operationName, variables and extensions), for example /graphql/acme?locale=de. A path below a GraphQL path counts as that path, and an origin may answer /graphql/<tenant>, ?locale=de or ?preview=1 differently, so each of them keeps its own entry:
    • The path is taken exactly as sent. /graphql/Acme, /graphql/acme and /graphql/acme/ are three entries.
    • The other parameters are read the way a server reads them and sorted by name, so ?locale=de&preview=1 and ?preview=1&locale=de are one entry, and %20 and + are the same space. The values of a repeated parameter keep the order they were sent in.
    • A parameter your origin does not read still splits the entry. A cache buster such as ?_=<timestamp>, or a tracking parameter with a value per visit, makes every request a miss.
    • Scheme and hostname are not part of it, whatever cache.http_key.host says: that key applies to plain HTTP only.
  • The query hash is a SHA-256 of the document exactly as sent, not normalized: two documents that differ only in whitespace, field order or aliases are two cache entries.
  • The operation name is included when the request names one.
  • The variables are included as a hash of the variables object when the request carries one. The hash is per service and does not depend on the order the members were sent in, so two clients sending the same variables in a different order share an entry.

Without its route, the key is the string request telemetry records (the cache key and the variables hash of a request’s detail in the portal) and the string the entry’s ck: surrogate key carries:

<sha256 of the query text>[#<operationName>][:<hash of the variables>]

So the name a request reports is always the name its entry can be purged by, and one purge of it removes the response on every route it was stored under. The variables hash cannot be recomputed from the variables, and the same variables in two services give two different values: copy it from the request detail rather than computing it.

For anything that is not a GraphQL operation, the key is the request method and the path the request was sent to, query string included, joined by a colon:

GET:/products/42?color=blue

Scheme and hostname are not part of it, so the same path requested through your service hostname and through a custom domain is one entry. If your origin answers the same path differently per hostname, set cache.http_key.host = "include": the key is then the full URL, GET:https://catalog-api.k7m2qp.graphpilot.cloud/products/42?color=blue, and each hostname keeps its own entry.

A request that carries a body is keyed by a SHA-256 of its body as well, so two POST requests to the same path with different bodies never share an entry:

POST:/search#9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

A request without a body keeps the key above.

The method is not part of a GraphQL key, on purpose: the same operation sent to the same route as POST, as GET with a query string, or as QUERY shares one entry. On a GET the operation travels in query, operationName, variables and extensions, which the route leaves out, because they are already in the key as the query hash, the operation name and the variables hash.

Clients generated by graphql-client, Apollo and urql send the whole .gql file on every request and pick the operation with operationName. For those clients the document in the key is the file, not the operation:

  • Adding or editing any operation in a document changes the key of every operation in it. A one-line change to an unrelated mutation is a cache flush for that file’s reads, from the first deployment that ships it.
  • Two clients share an entry only when their surrounding documents are byte-identical. A web client and a mobile client that both run GetProduct from different files warm two entries.

Nothing incorrect is served either way, because the operation name is part of the key. The fix is to register your operations: a persisted query is addressed by the hash of the one operation, so every client sending that hash shares one entry.

On top of the key, an entry is separated by who may share it: its bucket, your vary rules and whatever your origin’s Vary names. See which bucket an entry goes into.

What a request, a response and a cache entry may be at most is on limits. What the client’s own Cache-Control and a CORS preflight do is on headers.