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:
- Is this a WebSocket upgrade? A subscription handshake is never
cacheable.
PASS,CACHE_PASS_WEBSOCKET_UPGRADE. - Is the request method one you never cache?
cache.policy.uncacheable_methodslists them, and it holdsOPTIONSunless you change it.PASS,CACHE_PASS_UNCACHEABLE_METHOD. See preflights and the methods you never cache. - Does the matched client profile take this client off the cache?
PASS,CACHE_PASS_CLIENT_CACHE_OFF. See client profiles. - Is caching bypassed for this request? If your service configures
cache.policy.bypassand the expression matches, the request goes to your origin and its response is not stored.PASS,CACHE_PASS_BYPASS_POLICY. - Did the client ask for a response the origin has just confirmed? Only when
cache.request_directives.no_cacheis"honor": a requestCache-Controlofno-cacheormax-age=0goes to your origin and its response is not stored.PASS,CACHE_PASS_REQUEST_NO_CACHE. Ignored by default; see the client’s ownCache-Control. - 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. - 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-cacheheader. Invalidation keeps running, see automatic invalidation. - 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:
HITif it is still fresh.STALEif 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.
Writing: may this response be stored?
Section titled “Writing: may this response be stored?”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.
0. Only a query is ever stored
Section titled “0. Only a query is ever stored”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.
[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 carries | Code |
|---|---|
Set-Cookie, Set-Cookie2 | CACHE_ |
WWW-, Proxy- | CACHE_ |
Authentication-, Proxy- | CACHE_ |
Clear-, Set-Login | CACHE_ |
A GraphQL errors array, including partial data beside it | CACHE_ |
| A GraphQL response body GraphPilot cannot parse as JSON | CACHE_ |
Vary: * | CACHE_ |
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.
3. How long, and for whom
Section titled “3. How long, and for whom”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-Controlheader 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
privateon 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:
| Code | Why |
|---|---|
CACHE_ | The origin said no-store. |
CACHE_ | The origin said no-cache. |
CACHE_ | The deciding source stated no lifetime. |
CACHE_ | A private or named-scope response could not be partitioned for this request. |
CACHE_ | A signed-in caller’s response did not say it may be shared, and nothing identified the caller. |
CACHE_ | Neither your origin nor your schema stated a policy. |
4. The entry has to fit
Section titled “4. The entry has to fit”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.
What the entry is keyed by
Section titled “What the entry is keyed by”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,variablesandextensions), for example/graphql/acme?locale=de. A path below a GraphQL path counts as that path, and an origin may answer/graphql/<tenant>,?locale=deor?preview=1differently, so each of them keeps its own entry:- The path is taken exactly as sent.
/graphql/Acme,/graphql/acmeand/graphql/acme/are three entries. - The other parameters are read the way a server reads them and sorted by name, so
?locale=de&preview=1and?preview=1&locale=deare one entry, and%20and+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.hostsays: that key applies to plain HTTP only.
- The path is taken exactly as sent.
- 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=blueScheme 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#9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08A 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.
The document is part of the key
Section titled “The document is part of the key”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
GetProductfrom 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.
Limits and headers
Section titled “Limits and headers”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.