Skip to content

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.

  • The request, and a way to send it with curl as your client does (the same method, path, body and credential).
  • Access to your origin’s response headers, directly or in Insights.

Send the request and keep only the GraphPilot headers:

Terminal window
curl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache'
gp-cache: PASS
gp-cache-reason: CACHE_SKIPPED_SET_COOKIE

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

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.

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.

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.private source beside what your schema asked for, on a query whose types all declare a shared scope.
  • The origin response headers show cache-control: private with a set-cookie for an affinity cookie.
  • Edge codes in the Overview tab list CACHE_POLICY_ORIGIN_HEADER_IGNORED with the header verbatim.
  • Your origin, asked directly, sends it on a successful response:
Terminal window
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):

// Hono
app.use("/graphql", async (c, next) => {
await next();
c.header("Cache-Control", "no-transform");
});
// Express
app.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:

Terminal window
gcloud run services update YOUR_SERVICE --no-session-affinity

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

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.

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.

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.

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.

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.

CodeUsual causeFix
CACHE_SKIPPED_UNCACHEABLE_OPERATIONThe operation was a mutation or a subscription. Only a query is ever stored.None; this is correct.
CACHE_SKIPPED_NO_CACHE_DIRECTIVEYour 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_SKIPPED_AUTHENTICATION_HEADERThe response carried WWW-Authenticate, Clear-Site-Data or another header about one caller’s session.Send that header only on the responses that need it.
CACHE_SKIPPED_VARY_WILDCARDYour origin sent Vary: *.Name the headers the response varies on; see your origin’s Vary header.
CACHE_SKIPPED_SHIELD_ENTRY_EXHAUSTED, CACHE_SKIPPED_SHIELD_RESPONSE_NOT_STORABLEWith 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_SKIPPED_ENTRY_NOT_REPLACEDAn 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_SKIPPED_SERIALIZATION_FAILED, CACHE_SKIPPED_INSERT_FAILED, CACHE_PASS_READ_DISABLEDA fault on GraphPilot’s side, not in your configuration.If it persists, check the status page and contact support.

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.

Deploy the fix, or change your origin, then send the request twice:

Terminal window
curl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache:' # MISS
curl -sS -D- -o/dev/null "https://$GRAPHPILOT_HOST/products/42" | grep -i '^gp-cache:' # HIT

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

  • Configuration applies to responses stored after the deployment. Entries stored before it keep their old partitioning until they expire or you purge them.
  • A HIT for you is not a HIT for 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-Cookie the same response may report CACHE_SKIPPED_CREDENTIALED_REQUEST. Read the reason again after each fix.