Skip to content

HTTP headers

GraphPilot reserves the gp- prefix for headers it sets itself. The namespace is protected in both directions: a client cannot send a gp- header (every one is dropped by prefix), and your origin cannot send one of the headers GraphPilot sets (each is removed by name). For reading these headers when something looks wrong, see troubleshooting.

GraphPilot sets ten response headers. They are the only gp- names GraphPilot itself puts on a response.

HeaderValueControlled byWhen it appears
gp-cacheHIT, MISS, PASS or STALE. See cache statuses.response.cache_headers (default on)Every response that went through the cache, including a PASS. Absent on a request refused before the cache (a 401 from authentication, for example), and on every response the cache would otherwise have looked up while the service runs in lean mode. In lean mode, a request that bypasses the cache still reports PASS with its reason.
gp-cache-reasonA stable code: CACHE_PASS_* or CACHE_SKIPPED_*. See codes.response.cache_headersOn a PASS. Absent on a HIT, a STALE, and a MISS (which always means the response was stored).
gp-cache-hitsHow many times the copy on the cache server that answered has been served. Approximate: the entry can be stored on several servers, each counting on its own, so the value can go down between two reads. For hits per entry, use Insights.response.cache_headersOn a HIT or STALE.
gp-cache-ageSeconds since the entry was written.response.cache_headersOn a HIT or STALE.
gp-cache-max-ageThe freshness lifetime (TTL) the entry was stored with, in seconds.response.cache_headersOn a HIT or STALE.
gp-cache-remainingSeconds of freshness left.response.cache_headersOn a HIT or STALE.
gp-origin-retriesThe number of repeats. See origin retries.[origin.retry]; not affected by any [response] switchWhen the origin request had to be repeated at least once.
gp-surrogate-keyThe keys, space separated, whole keys only: the cache key (ck:) and root field keys (q:, m:) first, entity keys last.response.surrogate_key_header (default off), response.surrogate_key_header_max_bytes (default 8192)When switched on and the response carries surrogate keys that fit the size limit.
gp-surrogate-key-truncatedHow many keys gp-surrogate-key left out. If no key fits at all, this header is sent without gp-surrogate-key.response.surrogate_key_headerWhen switched on and some keys did not fit.
gp-request-idThe id GraphPilot recorded this request under, shown as the Request ID column in the portal’s Insights and as Entry ID in the request’s Overview tab. Minted per request, never stored with a cache entry.response.request_id_header (default off)When switched on, on every response.

Other headers GraphPilot writes or removes

Section titled “Other headers GraphPilot writes or removes”
HeaderWhat GraphPilot does
Cache-ControlWritten for the client when cache.policy.downstream is if_cacheable or always. Otherwise your origin’s header passes through. A response GraphPilot produces itself (a rejection) carries Cache-Control: no-store.
AllowWritten on a 405 (METHOD_NOT_ALLOWED), naming the method the service accepts for that operation.
Content-EncodingThe edge compresses the response for the client according to the client’s Accept-Encoding, on a hit exactly as on a miss.
x-cache, x-cache-hitsRemoved. The underlying edge platform (Fastly) adds them to describe GraphPilot’s own fetch from your origin, so they say nothing about the GraphPilot cache and could contradict gp-cache.
surrogate-keyRemoved. GraphPilot has read your origin’s Surrogate-Key header and its keys are part of gp-surrogate-key. See the Surrogate-Key header.

Your origin cannot set the gp- headers above, or the ones GraphPilot uses between its edge locations. GraphPilot removes them, by name, from every origin response before anything else reads it, so an origin cannot make its own behavior look like GraphPilot’s or get one of them stored in a cache entry. Other gp- names your origin sends are left alone.

Four keys under [response] decide whether and how much a client sees of these headers.

KeyDefaultControls
response.cache_headerstrueThe whole cache status family: gp-cache, gp-cache-reason, gp-cache-hits, gp-cache-age, gp-cache-max-age and gp-cache-remaining.
response.surrogate_key_headerfalsegp-surrogate-key and gp-surrogate-key-truncated.
response.surrogate_key_header_max_bytes8192How long the value of gp-surrogate-key may get. See size limit.
response.request_id_headerfalsegp-request-id. See request ids.
FactDetail
Independent of each otherEach switch changes only what leaves GraphPilot.
The cache is unaffectedA response is stored, served and purged the same way whether a client sees the headers or not. Surrogate keys are still written to the entry, so purging by key works with surrogate_key_header = false.
Telemetry is unaffectedRequest telemetry records the cache status, the reason code and the keys either way.
Applied on deliveryLike [response.headers], the switches also govern a hit served from an entry written before you changed them.
gp-origin-retriesNot affected by any switch.
The only way to withhold a gp- header[response.headers] cannot hide one: the namespace is protected, and naming one there refuses the deployment.

gp-surrogate-key stops at response.surrogate_key_header_max_bytes, 8,192 bytes by default.

RuleDetail
What it measuresThe value of gp-surrogate-key alone, not all headers of the response together. The default is half of the 16 KiB Node.js accepts for all headers of a response, so the other headers still fit.
Whole keys onlyA key is never cut in half.
OrderThe cache key (ck:), then the root field keys (q:, m:), then type keys, then static keys, keys from your origin’s Surrogate-Key header and empty-list keys, and entity keys last. When the list is cut, the entity keys go first.
Keys left outCounted in gp-surrogate-key-truncated. Without that header, the list is complete. If not even one key fits, gp-surrogate-key is left off and only gp-surrogate-key-truncated is sent.
One headerThe keys are never split across several header lines (RFC 9110, section 5.3).
Accepted values1 to 128000. The upper bound is Fastly’s limit for a single response header; past it the response would fail with a 503, so a larger value refuses the deployment.
The entry is unaffectedThe entry is tagged with every key, and a purge by any of them works whether it appeared in the header or not.

Everything a client sends reaches your origin unless a row below or your [origin.headers] filter says otherwise.

HeaderWhat GraphPilot does with itReaches your origin
gp-client-name, gp-client-versionKept for client profiles: a policy expression reads them as header:gp-client-name and header:gp-client-version. Removed from the request before it is forwarded.No
GP-Correlation-ID, or the name set in telemetry.correlation_headerIts value is recorded on the request’s telemetry row and on every log line for the request. Nothing is generated when it is absent. See correlating logs.Yes, unchanged, under the default name too. When a client sends it more than once, only the first value is forwarded, the one telemetry records. The value is whatever the client sent and is not validated, so treat it as untrusted input even though the default name is in the gp- namespace. To keep it out of that namespace, set telemetry.correlation_header to a name such as x-correlation-id.
Any other gp- headerDropped on arrival and logged as INGRESS_RESERVED_HEADER_DROPPED.No
Cache-ControlIts no-store, no-cache and max-age=0 directives are read under [cache.request_directives].Yes
Authorization, or the header an [auth.providers.<name>] block readsVerified by that provider; its claims are available as auth:<name>.<claim>. A credential makes the request credentialed for caching. A Bearer prefix is stripped before verification.Yes
CookieA cookie not on cache.cookies.allow makes the request credentialed.Yes, minus [origin.cookies]
Origin, Access-Control-Request-Method, Access-Control-Request-HeadersOn a cached CORS preflight, part of the entry’s key. See CORS.Yes
A header a [[cache.policy.vary]] entry namesRemoved and logged as INGRESS_CLAIMED_HEADER_DROPPED; the entry writes its own derived value under that name.Only as pass_to_origin allows

Every request header starting with gp- is removed when the request arrives, before any setting or policy expression reads it, and each one is logged as INGRESS_RESERVED_HEADER_DROPPED with its name. gp-client-name and gp-client-version are read first, as the table above says, and removed all the same. The correlation header is the one exception: it is set aside before the removal and written back afterwards, so it reaches your origin and is not logged as dropped. A name GraphPilot acts on itself (gp-signature, gp-timestamp, gp-client-ip, the client names, or any other name GraphPilot uses internally) is never excepted, even when configured as the correlation header.

What the directives a client sends on its own request do (RFC 9111, section 5.2.1). Each is a switch under [cache.request_directives]. A hard reload in a browser sends no-cache.

Request directiveKeyDefaultWhen honored
no-storeno_store"honor"The cache is still read, so the request can be answered from an entry another request stored. Only the response to this request is not written: CACHE_SKIPPED_REQUEST_NO_STORE.
no-cache, or max-age=0no_cache"ignore"The request goes straight to your origin with no lookup, and its response is not written back, the same path a bypass takes: PASS, CACHE_PASS_REQUEST_NO_CACHE.
DefaultWhy
no_cache = "ignore"Any caller can send it. Honored, it lets a client put every one of its requests past the cache and onto your origin. Even when honored, the response is not written back, so it never overwrites the entry other clients read.
no_store = "honor"The lookup still happens, so it never causes an origin request that would not have happened anyway; at most one entry is not written.

A value other than "honor" or "ignore", or an unknown key in the section, is refused when the configuration is read.

Header on your origin’s responseWhat GraphPilot does
Cache-ControlThe only header that decides a lifetime. Which directives are read, and in which order, is on lifetime from the origin header. stale-if-error is parsed and not acted on.
Surrogate-KeyKeys to tag the entry with; on a response to a write, keys to purge. See the Surrogate-Key header. Removed before the response reaches the client.
VaryEvery request header it names becomes part of the entry’s key. Vary: * is never stored (CACHE_SKIPPED_VARY_WILDCARD). See your origin’s Vary header.
Set-Cookie, Set-Cookie2A cookie not on cache.cookies.allow refuses storing (CACHE_SKIPPED_SET_COOKIE).
WWW-Authenticate, Proxy-Authenticate, Authentication-Info, Proxy-Authentication-Info, Clear-Site-Data, Set-LoginRefuse storing (CACHE_SKIPPED_AUTHENTICATION_HEADER), unless named in cache.headers.allow.
Content-EncodingA compressed body is decoded first (gzip, deflate, br, zstd). See compression.
Surrogate-ControlIgnored.
ExpiresIgnored.
Age, Date, ETag, Last-ModifiedNot consulted. An entry’s age comes from the cache itself.
The gp- headers GraphPilot setsRemoved. See reserved names.

Which of these refuse a response whatever its Cache-Control says is on when a response is cached.

GraphPilot changes a few headers on the request before forwarding it. Everything else arrives as the client sent it, minus the forwarding headers a client sent and what your [origin.headers] and [origin.cookies] filters remove.

HeaderWhat your origin receivesControlled by
HostThe hostname of your service’s origin URL, not the hostname the client called. The same name is sent for TLS (SNI) and checked against the origin’s certificate.The origin URL. See origin connection.
gp-timestampThe Unix time, in seconds, at which GraphPilot signed the request.Always sent. See request signing.
gp-signaturev1= followed by the Base64 HMAC-SHA256 signature over the method, path and query, timestamp and body hash.Always sent. A request GraphPilot cannot sign is not forwarded.
GP-Correlation-ID, or the name set in telemetry.correlation_headerThe value the client sent, unchanged and not validated, and only the first one when it was sent more than once. It is not covered by gp-signature, so treat it as untrusted input even under the default gp- name. Nothing is sent when the client sent none.telemetry.correlation_header; an [origin.headers] filter can remove it.
Accept-EncodingAlways identity, replacing what the client sent. GraphPilot reads the body to derive surrogate keys and strip injected fields, and compresses toward the client itself. An origin that compresses anyway is decoded (ORIGIN_RESPONSE_DECODED) or answered with 502. See codes.Not configurable. An [origin.headers] only list does not remove it.
The name you chooseThe GraphPilot request id, the same value as gp-request-id. A client-sent header of the same name is replaced.origin.request_id_header (default unset, so nothing is sent). A gp- name refuses the deployment.
gp-client-ipThe client’s IP address, IPv4 or IPv6, as the edge saw the connection; on a shielded request, the address the first edge location saw, never a GraphPilot address. Not sent when the address is unknown. A client cannot set it, since every gp- header a client sends is dropped on arrival. Like the other headers it is not part of gp-signature, so trust it on a request whose signature verifies.origin.forward_client_ip, default false, so nothing is sent. An [origin.headers] only list does not remove it. telemetry.client_ip does not change it either, but it decides whether a [telemetry.headers] rule may record it: only under client_ip = "full".
Every X-Forwarded-* header, Forwarded, X-Real-IP and the other forwarding headersNothing by default: the values a client sent are removed. With "pass", the values exactly as the client sent them. GraphPilot never writes these headers itself.origin.forwarded_headers, default "strip".
A [[cache.policy.vary]] headerNothing by default. With pass_to_origin = "anonymized", a keyed digest (v1= plus base64url); with "plain", the derived value.pass_to_origin
Content-Type, Content-LengthContent-Length is restated for the body actually sent. A GET whose operation GraphPilot rewrote (a persisted query hash resolved to its document) is sent as POST, or QUERY under method_rewrite = "post_to_query", with a JSON body and Content-Type: application/json.origin.method_rewrite

Nothing else in the gp- namespace reaches your origin: apart from the correlation header above, GraphPilot strips the whole namespace before it signs, then adds gp-timestamp and gp-signature, and gp-client-ip only when you turn on origin.forward_client_ip. A fresh cache hit is answered at the edge, so no request, and no request id, reaches your origin for it.

Forwarding headers tell a server where a request came from and which URL it asked for. A proxy in front of a server normally writes them. GraphPilot writes none of them, so on a request that reaches the edge every value in them is one the client chose.

  • Every header whose name starts with X-Forwarded-, in any case: X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port, X-Forwarded-Prefix, X-Forwarded-Client-Cert, and any other name with that prefix. X-Forwarded without the trailing dash is an ordinary header.
  • These ten names, matched whole and in any case: Forwarded, X-Real-IP, X-Client-IP, Client-IP, True-Client-IP, CF-Connecting-IP, Fastly-Client-IP, X-Original-URL, X-Rewrite-URL, X-Original-Host.

An origin that reads them gets one of two problems:

  • A spoofed address. A framework configured to trust a proxy (Express trust proxy, Rails, Symfony, nginx real_ip) takes the client’s address from X-Forwarded-For or Forwarded. A rate limit or an IP allowlist built on it uses an address the client picked.
  • A poisoned cache entry. An origin that builds absolute URLs from X-Forwarded-Host or X-Forwarded-Proto answers with the client’s URLs. These headers are not part of the cache key, so that answer is stored and served to every later caller.

With origin.forwarded_headers at its default, "strip", every forwarding header is removed right before the request is sent to your origin, after your [origin.headers] filter has run. Only the request to your origin changes:

  • GraphPilot itself reads the request as the client sent it. A policy expression such as header:x-forwarded-for still resolves, a [[cache.policy.vary]] entry can still derive a value from one, and telemetry.headers.request records the headers the client sent. telemetry.headers.origin_request records what your origin received, so the forwarding headers are absent there.
  • On a shielded service, the edge location passes the headers on to the shield, and the shield removes them before it calls your origin.
  • A cache hit never reaches your origin, so nothing is removed there.

The strip covers forwarding headers only. To keep other headers from your origin, list them in [origin.headers]. A trailing * matches a prefix:

[origin.headers]
hide = ["x-internal-debug", "x-envoy-*"]

An [origin.headers] only list that keeps a forwarding header while the strip is on refuses the deployment, because it asks to keep a header the strip removes. That applies to an entry naming one (x-forwarded-for, True-Client-IP) and to the pattern x-forwarded-*. A broader pattern such as x-* is accepted: it keeps your other x- headers, and the forwarding headers among them are still removed.

With origin.forwarded_headers = "pass", the headers reach your origin exactly as the client sent them, and both problems above are yours to handle: do not treat the values as facts about the connection, and put any header your response depends on into the cache key with a [[cache.policy.vary]] entry of the same name:

[origin]
forwarded_headers = "pass"
[[cache.policy.vary]]
name = "x-forwarded-host"
value = "header:x-forwarded-host"
pass_to_origin = "plain"

Read the client’s address from gp-client-ip, which no client can set. Turn on origin.forward_client_ip and trust the header on a request whose signature verifies. It is independent of origin.forwarded_headers: it is sent in both modes.

Every request is recorded under an id, shown as the Request ID column in Insights and as Entry ID in the request’s Overview tab. Two independent settings decide who else learns it:

SettingWho sees the idRules
response.request_id_header = trueThe caller, as gp-request-idOff by default.
origin.request_id_header = "<name>"Your origin, under the name you choose, commonly x-request-idA gp- name, or one that is not a valid header name, refuses the deployment. An [origin.headers] only list does not remove it. A client-sent header of the same name is replaced. Do not reuse the name of your telemetry.correlation_header, or the client’s correlation id is replaced on its way to your origin.

An id GraphPilot reported that your origin has no record of is the normal case for a fresh cache hit, not a lost request. gp-cache tells the two apart. Following one request across both logs is on correlating logs.

PathWhat GraphPilot does
POST /__gp/purgeReserved: answered by GraphPilot itself, on every hostname of the service, and never forwarded to your origin. A request GraphPilot did not send is answered with 401 and {"error":"unauthorized"}, and counts as one request unit. To purge, use the API or CLI. Only POST to exactly this path is reserved; other methods and other paths under /__gp/ are forwarded as usual.

CORS preflights, and the methods you never cache

Section titled “CORS preflights, and the methods you never cache”

A browser sends an OPTIONS preflight before a cross-origin POST. cache.policy.uncacheable_methods holds OPTIONS by default, so preflights skip the cache and reach your origin (CACHE_PASS_UNCACHEABLE_METHOD).

With uncacheable_methods = []Detail
A preflight becomes a cache candidateIt still needs a response policy that permits storing it. A preflight answered without a Cache-Control is not stored.
Keyed per questionGraphPilot adds Origin, Access-Control-Request-Method and Access-Control-Request-Headers to the entry’s vary rule, whatever your origin’s own Vary says, so one site is never handed the answer computed for another. Every browser on one site asking the same question reads the same entry.
RevocationA cached preflight keeps saying yes for its freshness lifetime. Revoking a site’s access means purging or waiting it out, the same question as authenticated requests.
Little to winWhen your origin’s Access-Control-Max-Age is already generous, browsers cache the preflight themselves and the edge sees few.
Method namesWritten in capitals. A name that is not a valid HTTP method is refused when the configuration is read.

A response GraphPilot produces itself carries none of the CORS headers your origin would have sent, so a browser blocks it and your app sees a network error instead of the code. That covers every response GraphPilot writes instead of your origin’s: the rejections in codes (such as a 400, 401, 403, 404, 405, 413 or 415), the 500, 502 and 504 GraphPilot answers when it cannot get a response from your origin, and the persisted query answers PERSISTED_QUERY_NOT_FOUND and PERSISTED_QUERY_NOT_SUPPORTED. List the origins that may read them in [response.cors]:

gpilot.toml
[response.cors]
allow_origins = ["https://app.example.com"]
allow_credentials = false

When a request’s Origin matches a listed origin (case-insensitively), a response GraphPilot produced itself carries:

HeaderValue
Access-Control-Allow-OriginThe request’s own Origin, or * when the list is ["*"]
VaryOrigin
Access-Control-Allow-Credentialstrue, only with allow_credentials = true
Access-Control-Expose-Headersgp-request-id, only with response.request_id_header on

A request without an Origin, or from an origin not on the list, gets none of them. A response from your origin, a cache hit included, carries exactly the CORS headers your origin sent, and a preflight (OPTIONS) still goes to your origin.