Why is nothing cached?
Every response says gp-cache: PASS, or the hit rate stays at zero. GraphPilot never refuses to
store silently: a PASS carries gp-cache-reason with the rule that refused, and each rule has
one usual cause. This guide goes from the code to the fix. The full order of the decision is on
when a response is cached.
The running example is one request you expect to be cached, for example
GET /products/42 or a GraphQL { products { id name } } query, sent to your service hostname.
What you need
Section titled “What you need”- The request, and a way to send it with
curlas your client does (the same method, path, body and credential). - Access to your origin’s response headers, directly or in Insights.
1. Read the reason
Section titled “1. Read the reason”Send the request and keep only the GraphPilot headers:
curl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache'gp-cache: PASSgp-cache-reason: CACHE_SKIPPED_SET_COOKIEThe same reason is in Insights, on the request’s Cache tab and in the list’s column as
Not cached because, and the filter of that name finds every request with it. A MISS means the response was stored; a second MISS for the same request
is a different problem, covered under the same query never
hits. No gp-cache header at all is
covered under no gp-cache header at all.
2. Match the reason to its cause
Section titled “2. Match the reason to its cause”No lifetime was stated
Section titled “No lifetime was stated”CACHE_SKIPPED_NO_CACHE_POLICY or CACHE_SKIPPED_NO_MAX_AGE. Neither your origin nor your schema
said how long the response stays fresh, so it is not stored.
Fix: send Cache-Control: s-maxage=60 (or max-age) from your origin, or give every type the
query selects a maxAge with @cacheControl. A bare private or
public states no lifetime either.
Your origin sets a cookie
Section titled “Your origin sets a cookie”CACHE_SKIPPED_SET_COOKIE. The most common cause. A framework that touches the session on every
request sends Set-Cookie on reads too, and a response that sets a cookie is never stored,
because a hit would hand that cookie to the next caller. The request’s Overview tab in Insights
lists the code under Edge codes and names the cookie.
Fix: set the session cookie only on the requests that change the session. If the cookie
carries no session, such as a load balancer’s affinity cookie, add it to
cache.cookies.allow.
See refusals that depend only on the
response.
Apollo Server sent no-store
Section titled “Apollo Server sent no-store”CACHE_SKIPPED_NO_STORE on a GraphQL response from Apollo Server. Its cache-control plugin sends
Cache-Control: no-store for every response its hints do not make cacheable, and an origin
header wins over your schema.
Fix: add @cacheControl hints that cover every field the query selects, or switch the header
off and let GraphPilot read the schema; see Apollo
Server.
When your hosting platform adds Cache-Control: private
Section titled “When your hosting platform adds Cache-Control: private”Some platforms write Cache-Control: private onto every response that carries no Cache-Control
of its own, together with a session-affinity cookie. Google Cloud Run does this when session
affinity is on: Google Frontend adds Set-Cookie: GAESA=..., Cache-Control: private and
Expires to every successful response without a Cache-Control, and leaves error responses and
a header your application sent alone. Other platforms with session affinity, such as Google Cloud
Load Balancing and Azure App Service, are reported to do the same. Check your origin’s headers
rather than relying on a list.
The cookie is harmless: GAESA, GCLB, AWSALB, ARRAffinity and the other common affinity
cookies are on the default cache.cookies.allow. The private is not. A bare private states no
lifetime, so it yields to your
schema, but it still
partitions the entry by cache.policy.private. A type you scoped PUBLIC or to a named
scope is then stored once per caller, and without a private
expression not at all (CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR). GraphPilot cannot tell
the platform’s private from one you sent on purpose, so it honors it.
How to recognize it:
- The request’s Segmentation in Insights is Private and lists your
cache.policy.privatesource beside what your schema asked for, on a query whose types all declare a shared scope. - The origin response headers show
cache-control: privatewith aset-cookiefor an affinity cookie. - Edge codes in the Overview tab list
CACHE_POLICY_ORIGIN_HEADER_IGNOREDwith the header verbatim. - Your origin, asked directly, sends it on a successful response:
curl -sS -D- -o /dev/null https://your-service.a.run.app/healthz | grep -iE 'cache-control|set-cookie'Fix: send a Cache-Control of your own that decides nothing. The platform only fills in a
missing header. Cache-Control: no-transform states no lifetime and no audience, so it yields to
your schema and adds no partition. It is also safe for every cache after the edge: without a
lifetime, and with Authorization on the request, a shared cache may not store the response
(RFC 9111 §3.5), and browsers do not cache a POST on their own. Set it wherever your responses
leave your application, replacing what a framework put there, and keep sending a real policy
where you mean one (no-store on mutations, private, max-age=... on per-person responses):
// Honoapp.use("/graphql", async (c, next) => { await next(); c.header("Cache-Control", "no-transform");});// Expressapp.use("/graphql", (req, res, next) => { res.setHeader("Cache-Control", "no-transform"); next();});Or turn session affinity off, if nothing needs it. Without the cookie the platform adds no header. On Cloud Run:
gcloud run services update YOUR_SERVICE --no-session-affinityA GraphQL subscription over a WebSocket stays on the instance it connected to for the life of the connection, so it does not need session affinity. State kept in instance memory between separate requests does.
The request carried a credential
Section titled “The request carried a credential”CACHE_SKIPPED_CREDENTIALED_REQUEST. The request had an Authorization header, a session cookie,
or the header an auth provider reads, and your origin’s Cache-Control did not say the response
may be shared. With no private expression to file it per caller, it is not stored.
Fix: if the response is the same for everyone, send public or s-maxage
(Cache-Control: s-maxage=60). If it differs per caller, configure cache.policy.private, and
mark the response private. See authenticated
requests.
A scope did not resolve
Section titled “A scope did not resolve”CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR. The response is partitioned, by a private from
your origin, a PRIVATE field or a named scope, and a value it is keyed on was missing for this
request. Its entry under Edge codes in the Overview tab says whether the request lacked the
value (missing) or nothing configures the name (unconfigured). The same code is reported when
the request carried a credential and a [[cache.policy.vary]] rule resolved to nothing or to the
empty string for it; that entry names the rules under unresolved.
Fix: for private, configure cache.policy.private and check that its source is on the
request (a verified token, not an expired one). For a named scope or a vary rule, give every name
in its @vary a [[cache.policy.vary]] entry, and check that the claim it reads is in the token,
not empty, and a string, a number or a Boolean. See partitioning your
cache.
The query selects a field the deployed schema does not know
Section titled “The query selects a field the deployed schema does not know”CACHE_SKIPPED_UNRESOLVED_SELECTION. The query selects a field, or a fragment on a type, that the
schema of the live deployment does not declare, at any depth. GraphPilot cannot tell who may
share that field’s value or for how long, so the response is not stored, whatever your origin’s
Cache-Control says. It is almost always an origin released ahead of its schema.
Fix: deploy the schema your origin runs. Responses are stored again from that deployment on. To avoid the gap, deploy the schema first; see deploy the schema before the origin.
The client asked not to store
Section titled “The client asked not to store”CACHE_SKIPPED_REQUEST_NO_STORE. The request itself carried Cache-Control: no-store. The cache
was still read, so this request can be a HIT, but its own response is never written. An HTTP
client library or a browser’s developer tools can add it.
Fix: test with a client that does not send it. If your callers send it and you want their
responses stored anyway, set cache.request_directives.no_store = "ignore"; see the client’s own
Cache-Control.
The response was an error
Section titled “The response was an error”A 4xx or 5xx status is not stored by default, even with a storable Cache-Control, and
reports PASS with CACHE_SKIPPED_ERROR_STATUS. Many frameworks attach one Cache-Control to every
response, errors included.
Fix: usually none, because this is the protection working. If you do want an error cached,
such as a 404 for something that does not exist, list its status in
cache.policy.store_error_statuses; see an error status is
not stored unless you opt
in. A GraphQL
response with an errors array is a different rule, CACHE_SKIPPED_GRAPHQL_ERRORS, and is never
stored.
A GraphQL response could not be parsed
Section titled “A GraphQL response could not be parsed”CACHE_SKIPPED_UNPARSEABLE_GRAPHQL_RESPONSE means the body of a GraphQL response was not JSON
GraphPilot could read. The client still received it unchanged, but it was not stored, because
without reading it GraphPilot cannot see errors in it or derive the keys a purge needs.
Fix: check that your origin answers GraphQL requests with a complete JSON body. If it does
and one operation always reports this code, look for very large integers: a JSON integer larger
than 18446744073709551615 is not read. Return such values as strings, through an ID or a custom
scalar. A body over 133 MiB is not read either. See refusals that depend only on what the
response
carries.
Other refusals
Section titled “Other refusals”| Code | Usual cause | Fix |
|---|---|---|
CACHE_ | The operation was a mutation or a subscription. Only a query is ever stored. | None; this is correct. |
CACHE_ | Your origin sent Cache-Control: no-cache, which forbids reuse without asking your origin first. | Send a max-age or s-maxage instead, if the response may be reused. |
CACHE_ | The response carried WWW-, Clear- or another header about one caller’s session. | Send that header only on the responses that need it. |
CACHE_ | Your origin sent Vary: *. | Name the headers the response varies on; see your origin’s Vary header. |
CACHE_, CACHE_ | With shielding, the shield had already used up the response’s lifetime, or answered with a response that may not be copied. | None for a stale or used-up copy: a later fresh response from the shield is stored. A response the shield passed has a reason of its own on the shield; fix that one. |
CACHE_ | An entry this request did not use (older than its client’s max_age, or unreadable) could not be replaced. | None; the entry is unchanged and still serves other requests. |
CACHE_, CACHE_, CACHE_ | A fault on GraphPilot’s side, not in your configuration. | If it persists, check the status page and contact support. |
Any other code
Section titled “Any other code”A code starting with CACHE_PASS_ means the cache was not consulted at all: a
bypass matched, a client
profile sets cache = "off", the method is uncacheable, the client sent
Cache-Control: no-cache while cache.request_directives.no_cache
is "honor", or it was a WebSocket upgrade. Every code, with its fix, is in the
reference.
Check that it works
Section titled “Check that it works”Deploy the fix, or change your origin, then send the request twice:
curl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache:' # MISScurl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache:' # HITA MISS carries no reason: it was stored. The HIT proves it can be read back. For a
per-caller response, send the second request with the same credential.
Pitfalls
Section titled “Pitfalls”- Configuration applies to responses stored after the deployment. Entries stored before it keep their old partitioning until they expire or you purge them.
- A
HITfor you is not aHITfor everyone. Each edge location fills its own cache, and a partitioned entry is shared only within its audience. If a global audience keeps your hit rate low, switch on shielding, so every location fills from one shared cache. - Fixing one refusal can reveal the next. The rules are checked in order, so after removing
Set-Cookiethe same response may reportCACHE_SKIPPED_CREDENTIALED_REQUEST. Read the reason again after each fix.
Related
Section titled “Related”- When a response is cached: every rule, in order
- Cache statuses
- Codes
- Troubleshooting