From ca0c57baddc5d23643a8133167d2b881c4bdc742 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 08:48:16 -0400 Subject: [PATCH 01/12] Clarify headless app guidance --- docs/cloud/headless-apps.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index c06da8580..7b9903453 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -9,23 +9,26 @@ human traffic. This poses a challenge for headless apps: all content retrieval is automated and often arrives in concentrated bursts during static builds and background revalidation. -Two components are critical for a successful headless setup on Craft Cloud: - -- **Request signing:** - - Use [request signing](request-signing.md) from trusted server-side code to - bypass the untrusted-bot policy. - - Signatures do not bypass shared capacity limits, so signed requests can - still receive `429` or `503` responses. - - Never expose the signing key to a browser or in a public environment - variable. -- **Automated retries:** - - Treat every non-2xx response as a failure. - - For `429` and `503` responses, honor `Retry-After` and use bounded retries - with exponential backoff and jitter. +Follow these guidelines for a successful headless setup on Craft Cloud: + +- **Request signing:** [Sign requests](request-signing.md) made by your + hosting platform, such as Vercel or Netlify, to bypass the stricter + untrusted-bot policy. +- **Automated retries:** Retries provide resilience against unavoidable + transient network errors, not just rate limits. Rate limits exist to protect + your origin. Without them, traffic bursts could overwhelm your database and + result in more problematic errors. + - Automated builds can issue many requests in a short window. If possible, + slow the request rate by reducing build concurrency or adding an interval + between requests. + [Nuxt's Nitro engine supports both options](https://nitro.build/config#prerender). + - When possible, send GraphQL queries with + [`GET` requests](/5.x/development/graphql.html#sending-requests-manually) so + successful responses can be served from Cloud's static cache. + - For error responses (4xx and up), honor `Retry-After`, ideally with + exponential backoff. - Only retry `POST` requests that contain read-only GraphQL queries—never mutations. - - Throw after retries are exhausted so `stale-while-revalidate` caching can - preserve the last successful result. ## Automated Retries From 4c7f3df189b1202183adcc0627befec6be8b5a22 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:04:18 -0400 Subject: [PATCH 02/12] Retry transient fetch failures --- docs/cloud/headless-apps.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 7b9903453..52626fb20 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -21,10 +21,10 @@ Follow these guidelines for a successful headless setup on Craft Cloud: - Automated builds can issue many requests in a short window. If possible, slow the request rate by reducing build concurrency or adding an interval between requests. - [Nuxt's Nitro engine supports both options](https://nitro.build/config#prerender). + [Nuxt’s Nitro engine supports both options](https://nitro.build/config#prerender). - When possible, send GraphQL queries with [`GET` requests](/5.x/development/graphql.html#sending-requests-manually) so - successful responses can be served from Cloud's static cache. + successful responses can be served from Cloud’s static cache. - For error responses (4xx and up), honor `Retry-After`, ideally with exponential backoff. - Only retry `POST` requests that contain read-only GraphQL queries—never @@ -42,6 +42,9 @@ const TOTAL_TIMEOUT = 30_000; const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); +const getBackoffDelay = (attempt) => + 1000 * 2 ** attempt * (0.5 + Math.random() / 2); + function getRetryDelay(response, attempt) { const retryAfter = response.headers.get('Retry-After'); @@ -50,7 +53,7 @@ function getRetryDelay(response, attempt) { return null; } - const backoff = 1000 * 2 ** attempt * (0.5 + Math.random() / 2); + const backoff = getBackoffDelay(attempt); const seconds = Number(retryAfter); if (Number.isFinite(seconds)) { @@ -80,7 +83,20 @@ export async function fetchWithRetry(request) { request.signal, AbortSignal.timeout(remaining), ]); - const response = await fetch(request.clone(), { signal }); + let response; + + try { + response = await fetch(request.clone(), { signal }); + } catch (error) { + const delay = getBackoffDelay(attempt); + + if (request.signal.aborted || Date.now() + delay >= deadline) { + throw error; + } + + await sleep(delay); + continue; + } if (response.ok) { return response; From 0affd9627a97c471d190b340b3dbc649926d1854 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:05:36 -0400 Subject: [PATCH 03/12] Clarify Nitro reference --- docs/cloud/headless-apps.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 52626fb20..b8e9e3823 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -21,7 +21,9 @@ Follow these guidelines for a successful headless setup on Craft Cloud: - Automated builds can issue many requests in a short window. If possible, slow the request rate by reducing build concurrency or adding an interval between requests. - [Nuxt’s Nitro engine supports both options](https://nitro.build/config#prerender). + [Nuxt’s Nitro engine](https://nitro.build/config#prerender) + ([no relation](https://craftcms.com/blog/retiring-craft-nitro)) supports both + options. - When possible, send GraphQL queries with [`GET` requests](/5.x/development/graphql.html#sending-requests-manually) so successful responses can be served from Cloud’s static cache. From 8939458f6b7b88f90f6e7458fec25996239d891c Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:07:52 -0400 Subject: [PATCH 04/12] Collapse retry wrapper example --- docs/cloud/headless-apps.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index b8e9e3823..16743304b 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -38,6 +38,7 @@ A maintained Fetch client such as [Ky](https://github.com/sindresorhus/ky) can provide this retry policy. If you prefer not to add a dependency, use a small wrapper around the native Fetch API: +::: details View Dependency-Free Fetch Wrapper ```js // Bound all attempts and delays. const TOTAL_TIMEOUT = 30_000; @@ -121,6 +122,7 @@ export async function fetchWithRetry(request) { } } ``` +::: ## Request Signatures From bd4104b1f65c38ea5a82e4dd9ad52a9542e77999 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:09:01 -0400 Subject: [PATCH 05/12] Clarify Nuxt retry example --- docs/cloud/headless-apps.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 16743304b..daec2f942 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -200,9 +200,8 @@ revalidation. ## Nuxt Example -Nuxt’s `$fetch` uses [ofetch](https://github.com/unjs/ofetch#-auto-retry), which -can retry requests but does not provide this `Retry-After` and backoff policy. -Keep the signed request in a server route and use the shared helper: +Keep the signed request in a Nuxt server route and use the dependency-free +wrapper above: ```js // server/api/blog.get.js From d9506ba9dc6f038174802a9ac4cb6f2e97540dcd Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:10:24 -0400 Subject: [PATCH 06/12] Abort retry delays with requests --- docs/cloud/headless-apps.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index daec2f942..ae87201ef 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -40,11 +40,11 @@ wrapper around the native Fetch API: ::: details View Dependency-Free Fetch Wrapper ```js +import { setTimeout as sleep } from 'node:timers/promises'; + // Bound all attempts and delays. const TOTAL_TIMEOUT = 30_000; -const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); - const getBackoffDelay = (attempt) => 1000 * 2 ** attempt * (0.5 + Math.random() / 2); @@ -97,7 +97,7 @@ export async function fetchWithRetry(request) { throw error; } - await sleep(delay); + await sleep(delay, undefined, { signal: request.signal }); continue; } @@ -118,7 +118,7 @@ export async function fetchWithRetry(request) { throw error; } - await sleep(delay); + await sleep(delay, undefined, { signal: request.signal }); } } ``` From 0db0258bc518e4f751c5e6f4c2fdb9616e43e5d5 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:12:46 -0400 Subject: [PATCH 07/12] Use Ky for retry examples --- docs/cloud/headless-apps.md | 126 ++++++++---------------------------- 1 file changed, 28 insertions(+), 98 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index ae87201ef..bf2651cfa 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -34,95 +34,10 @@ Follow these guidelines for a successful headless setup on Craft Cloud: ## Automated Retries -A maintained Fetch client such as [Ky](https://github.com/sindresorhus/ky) can -provide this retry policy. If you prefer not to add a dependency, use a small -wrapper around the native Fetch API: - -::: details View Dependency-Free Fetch Wrapper -```js -import { setTimeout as sleep } from 'node:timers/promises'; - -// Bound all attempts and delays. -const TOTAL_TIMEOUT = 30_000; - -const getBackoffDelay = (attempt) => - 1000 * 2 ** attempt * (0.5 + Math.random() / 2); - -function getRetryDelay(response, attempt) { - const retryAfter = response.headers.get('Retry-After'); - - // Retry only responses that include Retry-After. - if (!retryAfter) { - return null; - } - - const backoff = getBackoffDelay(attempt); - const seconds = Number(retryAfter); - - if (Number.isFinite(seconds)) { - return Math.max(backoff, seconds * 1000); - } - - const date = Date.parse(retryAfter); - - if (!Number.isNaN(date)) { - return Math.max(backoff, date - Date.now()); - } - - return backoff; -} - -export async function fetchWithRetry(request) { - const deadline = Date.now() + TOTAL_TIMEOUT; - - for (let attempt = 0; ; attempt++) { - const remaining = deadline - Date.now(); - - if (remaining <= 0) { - throw new Error('Craft request timed out'); - } - - const signal = AbortSignal.any([ - request.signal, - AbortSignal.timeout(remaining), - ]); - let response; - - try { - response = await fetch(request.clone(), { signal }); - } catch (error) { - const delay = getBackoffDelay(attempt); - - if (request.signal.aborted || Date.now() + delay >= deadline) { - throw error; - } - - await sleep(delay, undefined, { signal: request.signal }); - continue; - } - - if (response.ok) { - return response; - } - - const error = new Error(`Craft request failed: ${response.status}`); - const delay = getRetryDelay(response, attempt); - - await response.body?.cancel(); - - if (delay === null) { - throw error; - } - - if (Date.now() + delay >= deadline) { - throw error; - } - - await sleep(delay, undefined, { signal: request.signal }); - } -} -``` -::: +[Ky’s retry options](https://github.com/sindresorhus/ky#retry) support network +errors, `Retry-After`, exponential backoff, and jitter. Use an +[overall timeout](https://github.com/sindresorhus/ky#totaltimeout) to bound all +attempts and delays. The examples below use Ky for this policy. ## Request Signatures @@ -163,7 +78,7 @@ const getBlogEntries = unstable_cache( const result = await ky(request, { cache: 'no-store', retry: { - limit: Number.POSITIVE_INFINITY, + limit: 10, methods: ['post'], statusCodes: [429, 503], jitter: true, @@ -200,12 +115,11 @@ revalidation. ## Nuxt Example -Keep the signed request in a Nuxt server route and use the dependency-free -wrapper above: +Keep the signed request in a Nuxt server route: ```js // server/api/blog.get.js -import { fetchWithRetry } from '../utils/fetch-with-retry.js'; +import ky from 'ky'; import { getSignatureHeaders } from '../utils/request-signatures.js'; const { CRAFT_URL, CRAFT_GRAPHQL_TOKEN } = process.env; @@ -225,8 +139,16 @@ export default defineEventHandler(async () => { request.headers.set(name, value); } - const response = await fetchWithRetry(request); - const result = await response.json(); + const result = await ky(request, { + retry: { + limit: 10, + methods: ['post'], + statusCodes: [429, 503], + jitter: true, + }, + timeout: false, + totalTimeout: 30_000, + }).json(); if (result.errors?.length) { throw new Error(result.errors.map((error) => error.message).join('\n')); @@ -260,7 +182,7 @@ build rather than publish partial content: ```js --- -import { fetchWithRetry } from '../lib/fetch-with-retry.js'; +import ky from 'ky'; import { getSignatureHeaders } from '../lib/request-signatures.js'; const { CRAFT_URL, CRAFT_GRAPHQL_TOKEN } = process.env; @@ -278,8 +200,16 @@ for (const [name, value] of Object.entries(getSignatureHeaders(request))) { request.headers.set(name, value); } -const response = await fetchWithRetry(request); -const result = await response.json(); +const result = await ky(request, { + retry: { + limit: 10, + methods: ['post'], + statusCodes: [429, 503], + jitter: true, + }, + timeout: false, + totalTimeout: 30_000, +}).json(); if (result.errors?.length) { throw new Error(result.errors.map((error) => error.message).join('\n')); From e00aa5b671d25b1790e5fd346da6988d79c861cf Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:15:28 -0400 Subject: [PATCH 08/12] Tighten retry guidance --- docs/cloud/headless-apps.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index bf2651cfa..545a20149 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -35,9 +35,8 @@ Follow these guidelines for a successful headless setup on Craft Cloud: ## Automated Retries [Ky’s retry options](https://github.com/sindresorhus/ky#retry) support network -errors, `Retry-After`, exponential backoff, and jitter. Use an -[overall timeout](https://github.com/sindresorhus/ky#totaltimeout) to bound all -attempts and delays. The examples below use Ky for this policy. +errors, `Retry-After`, exponential backoff, and jitter. The examples below use +Ky for this policy. ## Request Signatures From 4b43c924c58bfeaae8da7d30ab1aeef264708366 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:17:32 -0400 Subject: [PATCH 09/12] Soften retry implementation guidance --- docs/cloud/headless-apps.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 545a20149..472319ef1 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -34,9 +34,10 @@ Follow these guidelines for a successful headless setup on Craft Cloud: ## Automated Retries -[Ky’s retry options](https://github.com/sindresorhus/ky#retry) support network -errors, `Retry-After`, exponential backoff, and jitter. The examples below use -Ky for this policy. +Resilient automated requests should handle network errors, `Retry-After`, +exponential backoff, and jitter. For brevity, the examples below use +[Ky](https://github.com/sindresorhus/ky#retry) for this policy, but no dependency +is required. ## Request Signatures From ef67b4a60a4d5462bca21c4fb2de3df571023d24 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:19:32 -0400 Subject: [PATCH 10/12] Use Ky retry status defaults --- docs/cloud/headless-apps.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 472319ef1..2d08a6af3 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -80,7 +80,6 @@ const getBlogEntries = unstable_cache( retry: { limit: 10, methods: ['post'], - statusCodes: [429, 503], jitter: true, }, timeout: false, @@ -143,7 +142,6 @@ export default defineEventHandler(async () => { retry: { limit: 10, methods: ['post'], - statusCodes: [429, 503], jitter: true, }, timeout: false, @@ -204,7 +202,6 @@ const result = await ky(request, { retry: { limit: 10, methods: ['post'], - statusCodes: [429, 503], jitter: true, }, timeout: false, From b2eba8e82ad90c2aaff48750208caeda7eb25120 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:21:39 -0400 Subject: [PATCH 11/12] Retry read-only GraphQL GET requests --- docs/cloud/headless-apps.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 2d08a6af3..0ec24ccf3 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -79,7 +79,7 @@ const getBlogEntries = unstable_cache( cache: 'no-store', retry: { limit: 10, - methods: ['post'], + methods: ['get', 'post'], jitter: true, }, timeout: false, @@ -141,7 +141,7 @@ export default defineEventHandler(async () => { const result = await ky(request, { retry: { limit: 10, - methods: ['post'], + methods: ['get', 'post'], jitter: true, }, timeout: false, @@ -201,7 +201,7 @@ for (const [name, value] of Object.entries(getSignatureHeaders(request))) { const result = await ky(request, { retry: { limit: 10, - methods: ['post'], + methods: ['get', 'post'], jitter: true, }, timeout: false, From 328a48f5fd335465a676db9c3e8040b5ced9668a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 18 Sep 2026 09:23:55 -0400 Subject: [PATCH 12/12] Clarify signing and retry boundaries --- docs/cloud/headless-apps.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 0ec24ccf3..b114e3079 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -11,9 +11,10 @@ builds and background revalidation. Follow these guidelines for a successful headless setup on Craft Cloud: -- **Request signing:** [Sign requests](request-signing.md) made by your - hosting platform, such as Vercel or Netlify, to bypass the stricter - untrusted-bot policy. +- **Request signing:** [Sign requests](request-signing.md) within your hosting + platform, such as Vercel or Netlify, to bypass the stricter untrusted-bot + policy. Never expose the signing key to browser code or a public environment + variable. - **Automated retries:** Retries provide resilience against unavoidable transient network errors, not just rate limits. Rate limits exist to protect your origin. Without them, traffic bursts could overwhelm your database and @@ -26,9 +27,9 @@ Follow these guidelines for a successful headless setup on Craft Cloud: options. - When possible, send GraphQL queries with [`GET` requests](/5.x/development/graphql.html#sending-requests-manually) so - successful responses can be served from Cloud’s static cache. - - For error responses (4xx and up), honor `Retry-After`, ideally with - exponential backoff. + successful responses can be cached by your hosting platform. + - For retryable error responses, honor `Retry-After`, ideally with exponential + backoff. - Only retry `POST` requests that contain read-only GraphQL queries—never mutations.