Skip to content

Connection to the origin

Every request the cache cannot answer goes on to your origin. This page covers that connection: scheme and port, what GraphPilot asks your origin to send, how long it waits, and when it tries again. Whether your origin can trust what arrives is on request signing and the origin allowlist.

GraphPilot speaks TLS to your origin when your origin URL is https://, and does not when it is http://. There is no separate switch: the scheme you register the service with is the scheme GraphPilot uses.

On an https:// origin the connection is verified: the certificate is checked against your origin’s hostname, and SNI is sent with that hostname. A certificate that does not match is a failed connection, not a warning.

An http:// origin is not encrypted, and the signature does not compensate: it proves who sent the request, not that nobody read it. If your origin is reachable over the public internet, register it as https://.

The origin URL belongs to the service, not to gpilot.toml. Change it in the portal or with gpilot service edit; it takes effect with the next deployment. There is no mutual TLS to the origin today.

Your origin receives its own hostname in Host, the host of the origin URL, not the hostname the client called (a custom domain or the service’s GraphPilot hostname). An origin that routes by Host, such as a virtual host or a platform that serves several apps from one address, has to answer on the hostname in the origin URL.

These options under [origin] change the request on its way to your origin. Each is off or neutral by default:

OptionWhat it does
origin.method_rewriteRewrites QUERY to POST, or POST to QUERY for query operations.
[origin.headers] and [origin.cookies]Header and cookie names removed before the request reaches your origin.
origin.request_id_headerSends the GraphPilot request id under a header name you choose.
origin.forward_client_ipSends the client’s IP address in gp-client-ip.

The full list of request headers your origin sees, and which ones GraphPilot adds or removes, is on request headers your origin receives.

GraphPilot sends your origin Accept-Encoding: identity, asking for an uncompressed body. It has to read the body to derive surrogate keys, to remove the fields it injected and to decide whether to cache it, and none of that works on compressed bytes. Compression toward the client is added on the way out, negotiated against the client’s own Accept-Encoding, so your clients still receive compressed responses.

An origin that compresses anyway still works, at a cost: a body encoded with gzip, deflate, br or zstd is decoded first, logged as ORIGIN_RESPONSE_DECODED with both sizes. A body that cannot be decoded is answered with 502 Bad Gateway:

CodeWhen
ORIGIN_RESPONSE_ENCODING_UNSUPPORTEDThe Content-Encoding names a coding GraphPilot cannot decode.
ORIGIN_RESPONSE_ENCODING_CORRUPTThe body did not decode under the coding its Content-Encoding named.
ORIGIN_RESPONSE_DECODED_TOO_LARGEThe body would decode to more than GraphPilot processes.

The fix for all three is to make your origin honor Accept-Encoding.

A plain HTTP response body reaches your client byte-identical to what your origin sent, after any decoding above. GraphPilot does not rewrite it, so a signature, a digest or an ETag your origin computed over the body stays valid.

Three optional keys under [origin] in gpilot.toml bound how long the edge waits for your origin:

KeyWhat it boundsDefaultCeiling
connect_timeout_msAccepting the TCP connection and completing the TLS handshake10005000 on every plan
first_byte_timeout_msWaiting for the first byte of the response, after the request was sent15000, or your plan’s ceiling if that is lowerPer plan
between_bytes_timeout_msWaiting between two bytes of a response that has already started1000060000 on every plan

They take effect with your next deployment. An origin that does not connect or send its first byte in time fails the request with 502 and BAD_GATEWAY, unless a retry succeeds.

Planfirst_byte_timeout_ms ceiling
Glider (Free)5000
Jet (Starter)15000
Airliner (Business)30000
Jumbo (Enterprise)30000

A ceiling is not a recommendation: failing in two seconds is often better than failing in thirty.

On Glider (Free), the default is lower. An unconfigured service gets its plan’s ceiling where that is below 15000, so the edge waits 5 seconds for the first byte. A container scaling up from zero can take longer, and the request fails with a 502 while it boots. Keep an instance warm, or move to a plan whose ceiling covers your cold start.

Nothing is quietly adjusted. The deployment is refused, with a message naming the value, when:

  • a value exceeds its ceiling. The message names the ceiling and the plan:

    origin.first_byte_timeout_ms = 30000 exceeds the maximum of 5000 ms on the FREE plan.
    Lower the value or upgrade the plan.

    After a downgrade, your next deployment is refused until the value comes down.

  • a value is below 100 ms. The values are milliseconds, and a smaller one is almost certainly seconds written in the wrong unit:

    origin.first_byte_timeout_ms = 30 is below the minimum of 100 ms. The value is milliseconds;
    30 would fail every request to a healthy origin.

A request may spend at most 120 seconds at the edge: the wall clock Fastly’s edge network, which GraphPilot runs on, allows per request. With retries, every attempt gets the full connect and first-byte timeouts, and the delays between attempts add up, so the worst case has to fit:

(max_retries + 1) × (connect_timeout_ms + first_byte_timeout_ms) + backoff delays ≤ 120000 ms

A configuration that does not fit is refused by the deployment build, with a message naming the worst case and asking you to lower origin.first_byte_timeout_ms or origin.retry.max_retries. between_bytes_timeout_ms is not counted, because how long a body takes depends on its size. This is also why no plan allows a first-byte timeout above 30000: a retry could never run.

The edge also allows each request at most 32 backend requests, and every attempt is one of them. Keep max_retries at 30 or below: the deployment build checks the time budget only, so a higher value is accepted, and the attempts past the limit cannot be sent.

Fitting the budget is not fitting your client’s patience: two attempts at 30 seconds are a minute of waiting. Pick the numbers together.

A request that fails before it reaches your application can be sent again without the client learning that it failed. GraphPilot does this only when you ask it to.

The keys, under [origin.retry]:

KeyTypeDefaultWhat it does
enabledbooleanfalseWhether failed origin requests are retried at all.
max_retriesinteger1How many additional attempts a failed request gets. 1 means at most two attempts in total.
status_codeslist of integers[502, 503, 504]The origin response statuses that trigger a retry.
on_network_errorbooleantrueWhether a transport failure is retried.
retry_mutationsbooleanfalseWhether GraphQL mutations are retried. Does not apply to plain HTTP requests.
retry_unsafe_methodsbooleanfalseWhether plain HTTP requests with a method other than GET, HEAD or OPTIONS are retried. Does not apply to GraphQL requests.
backoffenum"fixed"How the delay between attempts is computed.
delay_msinteger1000The base delay in milliseconds.
max_delay_msinteger2000The ceiling on any single delay, for both fixed and exponential.

A retryable status. The default list is the three gateway statuses that mean the request did not reach your application. A 500 is deliberately not in it: a 500 is your application answering, and answering twice is rarely what a client wants. If your origin uses 5xx semantically, add to the list rather than assuming.

A transport failure, covered by on_network_error. The origin was unreachable, the connection dropped, or a timeout expired. There is no status to match on, because nothing came back at all.

GraphQL mutations are not retried by default. Enabling [origin.retry] repeats queries; a mutation is sent once unless you set retry_mutations = true. The retryable statuses usually mean the request never reached your application, so the mutation never ran, but that holds only as far as your origin’s status codes do. A proxy in front of your application can time out with a 504 while the application is still committing, and the write would then run twice.

A timeout says nothing about whether your origin already acted. With on_network_error on (the default once retries are enabled), a transport failure is retried, a timeout included. With retry_mutations = true, that covers mutations too, so a mutation that timed out after your origin executed it is sent again. Set retry_mutations = true only if your origin answers a gateway status before it executes anything, or your mutations are idempotent, for example by an idempotency key.

A plain HTTP request is retried only when its method is GET, HEAD or OPTIONS. A POST, PUT, PATCH, DELETE or any other method is sent once, because repeating a write your origin may already have executed would execute it twice. Set retry_unsafe_methods = true only for an origin that makes its writes idempotent, for example by an idempotency key. The two switches do not overlap: retry_mutations never decides a plain HTTP request, and retry_unsafe_methods never decides a GraphQL one.

A WebSocket handshake is never retried, whatever the configuration says. The connection is handed to your origin on the first attempt, so there is no second attempt to make; a handshake that fails reaches the client as a 502. See subscriptions and WebSockets.

StrategyBehaviorWhen to use it
noneRetry immediately.Your origin is a pool and a failure means “this instance is unhealthy”. The next attempt lands elsewhere, and waiting only adds latency.
fixed (default)Wait delay_ms before every retry, capped at max_delay_ms.The general case.
exponentialWait delay_ms, then double before each further attempt, capped at max_delay_ms.The failure is more likely load than one bad instance, so attempts spread out instead of arriving together.

A delay is spent suspended, so it costs wall clock, not CPU, and counts against the 120-second budget.

A response whose origin request was repeated carries the count, and the header is absent when there were none:

gp-origin-retries: 2

The same event is logged as ORIGIN_RETRIED, once per repeated attempt, which is what to alert on: retries succeeding quietly still mean your origin is failing.

A retry happens below the cache: the response that eventually succeeds goes through the normal store decision, and a cache hit never retries. A background revalidation is an ordinary origin request and retries like any other.

With shielding, a miss at an edge location goes to the shield location first, and only the shield connects to your origin. The shield’s request to your origin uses the same TLS, signing and timeouts as any other.

The extra hop counts against the 120-second budget. The edge waits for the shield’s whole origin sequence, every retry attempt and the gaps between them, plus a fixed 5-second hop buffer, rather than a single attempt’s timeout: the shield may still be legitimately retrying when a single attempt’s timeout would have expired. An unset first_byte_timeout_ms counts as 15000 ms here. A configuration whose total would exceed the 120-second budget is refused by the deployment build, naming origin.shield.

Retries do not double at your origin. With a shield, your origin sees no more attempts per request than your [origin.retry] policy allows, the same as without one.

When the shield cannot be used, a read falls back to your origin and a mutation is refused; how each case is answered is on when the shield cannot be used, and how a shielded request counts for usage on request units.