From 0db6c18ec817e98489e71016cc94c80b62a70e7d Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Fri, 22 May 2026 14:58:52 +0530 Subject: [PATCH 1/5] Add retry and 15s connect timeout to postinstall WP download Fixes STU-1749. The postinstall download script relied on undici's default 10s connect timeout and gave up after a single attempt, so a transient slow TLS handshake to wordpress.org (or any of the other artifact hosts) failed the entire install. Wrap the fetch in a fetchWithRetry helper that uses an undici Agent with connect.timeout=15000 and retries up to 3 times with exponential backoff. --- scripts/download-wp-server-files.ts | 42 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index 257705fd1d..cb36cdcb93 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -6,10 +6,46 @@ import { getPhpMyAdminInstallSteps, } from '@wp-playground/tools'; import fs from 'fs-extra'; +import { Agent } from 'undici'; import { z } from 'zod'; import { extractZip } from '@studio/common/lib/extract-zip'; import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../apps/studio/src/constants'; +const CONNECT_TIMEOUT_MS = 15_000; +const MAX_DOWNLOAD_ATTEMPTS = 3; +const downloadDispatcher = new Agent( { connect: { timeout: CONNECT_TIMEOUT_MS } } ); + +async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { + let lastError: unknown; + for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) { + try { + const response = await fetch( url, { + // `dispatcher` is an undici-specific option not in the standard RequestInit type. + dispatcher: downloadDispatcher, + } as RequestInit ); + if ( ! response.ok ) { + throw new Error( `Request failed with status code: ${ response.status }` ); + } + return Buffer.from( await response.arrayBuffer() ); + } catch ( error ) { + lastError = error; + const message = error instanceof Error ? error.message : String( error ); + if ( attempt < MAX_DOWNLOAD_ATTEMPTS ) { + const delayMs = 1000 * 2 ** ( attempt - 1 ); + console.warn( + `[${ name }] Download failed (attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }): ${ message }. Retrying in ${ delayMs }ms...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); + } else { + console.error( + `[${ name }] Download failed after ${ MAX_DOWNLOAD_ATTEMPTS } attempts: ${ message }` + ); + } + } + } + throw lastError; +} + const WP_SERVER_FILES_PATH = path.join( import.meta.dirname, '..', 'wp-files' ); const PHPMYADMIN_PATCH_FILES_PATH = path.join( import.meta.dirname, '..', 'apps', 'cli', 'php' ); const PHPMYADMIN_LOCAL_PATCH_FILES = new Map< string, string >( [ @@ -117,11 +153,7 @@ async function downloadFile( file: FileToDownload ): Promise< void > { } const url = await file.getUrl(); - const response = await fetch( url ); - if ( ! response.ok ) { - throw new Error( `Request failed with status code: ${ response.status }` ); - } - const buffer = Buffer.from( await response.arrayBuffer() ); + const buffer = await fetchWithRetry( name, url ); await fs.writeFile( zipPath, buffer ); if ( name === 'wp-cli' ) { From 698fcbd1e0e44cc97bf84eaee550f23e9d033bf1 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Fri, 22 May 2026 15:24:16 +0530 Subject: [PATCH 2/5] Address review: fail fast on non-retriable HTTP, normalize errors, dedupe logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don't retry 4xx responses other than 429 — those are non-transient and only waste time on retries. - Type lastError as Error and normalize non-Error throws so the final re-throw preserves a real stack trace. - Drop the in-helper console.error on the final attempt; downloadFiles already logs the thrown error once before exiting. --- scripts/download-wp-server-files.ts | 42 ++++++++++++++++------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index cb36cdcb93..848516511c 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -16,34 +16,38 @@ const MAX_DOWNLOAD_ATTEMPTS = 3; const downloadDispatcher = new Agent( { connect: { timeout: CONNECT_TIMEOUT_MS } } ); async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { - let lastError: unknown; + let lastError: Error | undefined; for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) { + let response: Response | undefined; try { - const response = await fetch( url, { + response = await fetch( url, { // `dispatcher` is an undici-specific option not in the standard RequestInit type. dispatcher: downloadDispatcher, } as RequestInit ); - if ( ! response.ok ) { - throw new Error( `Request failed with status code: ${ response.status }` ); - } - return Buffer.from( await response.arrayBuffer() ); } catch ( error ) { - lastError = error; - const message = error instanceof Error ? error.message : String( error ); - if ( attempt < MAX_DOWNLOAD_ATTEMPTS ) { - const delayMs = 1000 * 2 ** ( attempt - 1 ); - console.warn( - `[${ name }] Download failed (attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }): ${ message }. Retrying in ${ delayMs }ms...` - ); - await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); - } else { - console.error( - `[${ name }] Download failed after ${ MAX_DOWNLOAD_ATTEMPTS } attempts: ${ message }` - ); + lastError = error instanceof Error ? error : new Error( String( error ) ); + } + + if ( response ) { + if ( response.ok ) { + return Buffer.from( await response.arrayBuffer() ); + } + lastError = new Error( `Request failed with status code: ${ response.status }` ); + // Fail fast on non-transient HTTP errors (4xx other than 429). + if ( response.status < 500 && response.status !== 429 ) { + throw lastError; } } + + if ( attempt < MAX_DOWNLOAD_ATTEMPTS ) { + const delayMs = 1000 * 2 ** ( attempt - 1 ); + console.warn( + `[${ name }] Download failed (attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }): ${ lastError?.message }. Retrying in ${ delayMs }ms...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); + } } - throw lastError; + throw lastError ?? new Error( `[${ name }] Download failed` ); } const WP_SERVER_FILES_PATH = path.join( import.meta.dirname, '..', 'wp-files' ); From 0e1c2d939594a332dd581c13a892e3f796ca2c32 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Fri, 22 May 2026 15:32:03 +0530 Subject: [PATCH 3/5] Reuse retry logic for fetchLatestGithubRelease Extract a generic withRetry helper backed by a NonRetriableError class so any network call in this script can opt into the same retry behavior. Refactor fetchWithRetry to use it and wrap fetchLatestGithubRelease in withRetry so the GitHub API lookup also benefits from the 15s connect timeout and 3-attempt backoff instead of its previous 5s total timeout with no retries. --- scripts/download-wp-server-files.ts | 116 ++++++++++++++++------------ 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index 848516511c..0564ac45a3 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -12,42 +12,60 @@ import { extractZip } from '@studio/common/lib/extract-zip'; import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../apps/studio/src/constants'; const CONNECT_TIMEOUT_MS = 15_000; -const MAX_DOWNLOAD_ATTEMPTS = 3; -const downloadDispatcher = new Agent( { connect: { timeout: CONNECT_TIMEOUT_MS } } ); +const MAX_ATTEMPTS = 3; +const sharedDispatcher = new Agent( { connect: { timeout: CONNECT_TIMEOUT_MS } } ); -async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { +class NonRetriableError extends Error {} + +async function withRetry< T >( + name: string, + fn: () => Promise< T >, + options: { maxAttempts?: number } = {} +): Promise< T > { + const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS; let lastError: Error | undefined; - for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) { - let response: Response | undefined; + for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { try { - response = await fetch( url, { - // `dispatcher` is an undici-specific option not in the standard RequestInit type. - dispatcher: downloadDispatcher, - } as RequestInit ); + return await fn(); } catch ( error ) { lastError = error instanceof Error ? error : new Error( String( error ) ); - } - - if ( response ) { - if ( response.ok ) { - return Buffer.from( await response.arrayBuffer() ); - } - lastError = new Error( `Request failed with status code: ${ response.status }` ); - // Fail fast on non-transient HTTP errors (4xx other than 429). - if ( response.status < 500 && response.status !== 429 ) { + if ( lastError instanceof NonRetriableError ) { throw lastError; } + if ( attempt < maxAttempts ) { + const delayMs = 1000 * 2 ** ( attempt - 1 ); + console.warn( + `[${ name }] Attempt ${ attempt }/${ maxAttempts } failed: ${ lastError.message }. Retrying in ${ delayMs }ms...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); + } } + } + throw lastError ?? new Error( `[${ name }] Failed after ${ maxAttempts } attempts` ); +} - if ( attempt < MAX_DOWNLOAD_ATTEMPTS ) { - const delayMs = 1000 * 2 ** ( attempt - 1 ); - console.warn( - `[${ name }] Download failed (attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }): ${ lastError?.message }. Retrying in ${ delayMs }ms...` - ); - await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); - } +function throwForHttpStatus( context: string, status: number, statusText?: string ): never { + const message = `${ context } failed with status code: ${ status }${ + statusText ? ` ${ statusText }` : '' + }`; + // 4xx (other than 429) are non-transient — fail fast instead of retrying. + if ( status < 500 && status !== 429 ) { + throw new NonRetriableError( message ); } - throw lastError ?? new Error( `[${ name }] Download failed` ); + throw new Error( message ); +} + +async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { + return withRetry( name, async () => { + const response = await fetch( url, { + // `dispatcher` is an undici-specific option not in the standard RequestInit type. + dispatcher: sharedDispatcher, + } as RequestInit ); + if ( ! response.ok ) { + throwForHttpStatus( 'Request', response.status ); + } + return Buffer.from( await response.arrayBuffer() ); + } ); } const WP_SERVER_FILES_PATH = path.join( import.meta.dirname, '..', 'wp-files' ); @@ -66,32 +84,34 @@ const partialGithubReleaseSchema = z.object( { } ); export async function fetchLatestGithubRelease( repo: string ) { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'wp-studio-cli', - }; - - // GitHub API has rate limits: - // - 60 requests/hour for unauthenticated requests - // - 5,000 requests/hour with token authentication - // In CI environments, the IP-based rate limit is shared across runners, - // so we authenticate with GITHUB_TOKEN when available. - if ( process.env.GITHUB_TOKEN ) { - headers.Authorization = `token ${ process.env.GITHUB_TOKEN }`; - } + return withRetry( `github:${ repo }`, async () => { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'wp-studio-cli', + }; - const response = await fetch( `https://api.github.com/repos/${ repo }/releases/latest`, { - headers, - signal: AbortSignal.timeout( 5000 ), - } ); + // GitHub API has rate limits: + // - 60 requests/hour for unauthenticated requests + // - 5,000 requests/hour with token authentication + // In CI environments, the IP-based rate limit is shared across runners, + // so we authenticate with GITHUB_TOKEN when available. + if ( process.env.GITHUB_TOKEN ) { + headers.Authorization = `token ${ process.env.GITHUB_TOKEN }`; + } - if ( ! response.ok ) { - throw new Error( `GitHub API request failed: ${ response.status } ${ response.statusText }` ); - } + const response = await fetch( `https://api.github.com/repos/${ repo }/releases/latest`, { + headers, + dispatcher: sharedDispatcher, + } as RequestInit ); - const rawResponse: unknown = await response.json(); + if ( ! response.ok ) { + throwForHttpStatus( 'GitHub API request', response.status, response.statusText ); + } - return partialGithubReleaseSchema.parse( rawResponse ); + const rawResponse: unknown = await response.json(); + + return partialGithubReleaseSchema.parse( rawResponse ); + } ); } type MaybePromise< T > = T | Promise< T >; From 785ed4cc0090dc93c90e1ad6a6d7cca2975819b7 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Fri, 22 May 2026 16:02:02 +0530 Subject: [PATCH 4/5] Consolidate postinstall retry logic into scripts/lib/with-retry Both download-wp-server-files.ts and download-language-packs.ts had their own fetchWithRetry helpers with overlapping behavior. Extract withRetry, NonRetriableError, throwForHttpStatus, and a shared undici Agent (15s connect timeout) into scripts/lib/with-retry.ts and have both scripts import from there. Also bring download-language-packs in line with the improvements already in download-wp-server-files: fail fast on 4xx (other than 429) and use the shared 15s connect timeout instead of undici's 10s default. --- scripts/download-language-packs.ts | 56 ++++++++++------------------- scripts/download-wp-server-files.ts | 46 +----------------------- scripts/lib/with-retry.ts | 49 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 82 deletions(-) create mode 100644 scripts/lib/with-retry.ts diff --git a/scripts/download-language-packs.ts b/scripts/download-language-packs.ts index 54bded40c9..5ebd5db770 100644 --- a/scripts/download-language-packs.ts +++ b/scripts/download-language-packs.ts @@ -3,42 +3,10 @@ import path from 'path'; import fs from 'fs-extra'; import { extractZip } from '../tools/common/lib/extract-zip'; import { WP_LOCALES } from '../tools/common/lib/wp-locales'; +import { sharedDispatcher, throwForHttpStatus, withRetry } from './lib/with-retry'; const WP_SERVER_FILES_PATH = path.join( import.meta.dirname, '..', 'wp-files' ); -const MAX_RETRIES = 3; -const INITIAL_RETRY_DELAY_MS = 1000; - -async function fetchWithRetry( url: string, attempt = 1 ): Promise< Response > { - try { - const response = await fetch( url ); - if ( response.ok ) { - return response; - } - if ( attempt >= MAX_RETRIES ) { - throw new Error( `HTTP ${ response.status }` ); - } - const delay = INITIAL_RETRY_DELAY_MS * Math.pow( 2, attempt - 1 ); - console.warn( - `[language-packs] Request failed (status ${ response.status }), retrying in ${ delay }ms (attempt ${ attempt }/${ MAX_RETRIES })...` - ); - await new Promise( ( resolve ) => setTimeout( resolve, delay ) ); - return fetchWithRetry( url, attempt + 1 ); - } catch ( error ) { - if ( attempt >= MAX_RETRIES ) { - throw error; - } - const delay = INITIAL_RETRY_DELAY_MS * Math.pow( 2, attempt - 1 ); - console.warn( - `[language-packs] Request failed (${ - error instanceof Error ? error.message : error - }), retrying in ${ delay }ms (attempt ${ attempt }/${ MAX_RETRIES })...` - ); - await new Promise( ( resolve ) => setTimeout( resolve, delay ) ); - return fetchWithRetry( url, attempt + 1 ); - } -} - interface TranslationEntry { language: string; package: string; @@ -77,8 +45,15 @@ async function downloadTranslationsFromApi( destPath: string, label: string ): Promise< void > { - const response = await fetchWithRetry( apiUrl ); - const data: TranslationsApiResponse = await response.json(); + const data = await withRetry( `language-packs:${ label }`, async () => { + const response = await fetch( apiUrl, { + dispatcher: sharedDispatcher, + } as RequestInit ); + if ( ! response.ok ) { + throwForHttpStatus( 'Translations API request', response.status ); + } + return ( await response.json() ) as TranslationsApiResponse; + } ); const translationsToDownload = data.translations.filter( ( t ) => WP_LOCALES.includes( t.language ) @@ -92,11 +67,18 @@ async function downloadTranslationsFromApi( for ( const translation of translationsToDownload ) { const { language, package: packageUrl } = translation; - const zipResponse = await fetchWithRetry( packageUrl ); + const buffer = await withRetry( `language-packs:${ label }:${ language }`, async () => { + const response = await fetch( packageUrl, { + dispatcher: sharedDispatcher, + } as RequestInit ); + if ( ! response.ok ) { + throwForHttpStatus( 'Translation download', response.status ); + } + return Buffer.from( await response.arrayBuffer() ); + } ); const safeLabel = label.replace( /\//g, '-' ); const zipPath = path.join( os.tmpdir(), `wp-language-${ safeLabel }-${ language }.zip` ); - const buffer = Buffer.from( await zipResponse.arrayBuffer() ); await fs.writeFile( zipPath, buffer ); await extractZip( zipPath, destPath ); await fs.remove( zipPath ); diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index 0564ac45a3..bcad4c0948 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -6,54 +6,10 @@ import { getPhpMyAdminInstallSteps, } from '@wp-playground/tools'; import fs from 'fs-extra'; -import { Agent } from 'undici'; import { z } from 'zod'; import { extractZip } from '@studio/common/lib/extract-zip'; import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../apps/studio/src/constants'; - -const CONNECT_TIMEOUT_MS = 15_000; -const MAX_ATTEMPTS = 3; -const sharedDispatcher = new Agent( { connect: { timeout: CONNECT_TIMEOUT_MS } } ); - -class NonRetriableError extends Error {} - -async function withRetry< T >( - name: string, - fn: () => Promise< T >, - options: { maxAttempts?: number } = {} -): Promise< T > { - const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS; - let lastError: Error | undefined; - for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { - try { - return await fn(); - } catch ( error ) { - lastError = error instanceof Error ? error : new Error( String( error ) ); - if ( lastError instanceof NonRetriableError ) { - throw lastError; - } - if ( attempt < maxAttempts ) { - const delayMs = 1000 * 2 ** ( attempt - 1 ); - console.warn( - `[${ name }] Attempt ${ attempt }/${ maxAttempts } failed: ${ lastError.message }. Retrying in ${ delayMs }ms...` - ); - await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); - } - } - } - throw lastError ?? new Error( `[${ name }] Failed after ${ maxAttempts } attempts` ); -} - -function throwForHttpStatus( context: string, status: number, statusText?: string ): never { - const message = `${ context } failed with status code: ${ status }${ - statusText ? ` ${ statusText }` : '' - }`; - // 4xx (other than 429) are non-transient — fail fast instead of retrying. - if ( status < 500 && status !== 429 ) { - throw new NonRetriableError( message ); - } - throw new Error( message ); -} +import { sharedDispatcher, throwForHttpStatus, withRetry } from './lib/with-retry'; async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { return withRetry( name, async () => { diff --git a/scripts/lib/with-retry.ts b/scripts/lib/with-retry.ts new file mode 100644 index 0000000000..d1010484b9 --- /dev/null +++ b/scripts/lib/with-retry.ts @@ -0,0 +1,49 @@ +import { Agent } from 'undici'; + +export const DEFAULT_MAX_ATTEMPTS = 3; +export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; + +// Node's default undici connect timeout (10s) is too aggressive on slow networks during postinstall. +export const sharedDispatcher = new Agent( { + connect: { timeout: DEFAULT_CONNECT_TIMEOUT_MS }, +} ); + +export class NonRetriableError extends Error {} + +export async function withRetry< T >( + name: string, + fn: () => Promise< T >, + options: { maxAttempts?: number } = {} +): Promise< T > { + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + let lastError: Error | undefined; + for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { + try { + return await fn(); + } catch ( error ) { + lastError = error instanceof Error ? error : new Error( String( error ) ); + if ( lastError instanceof NonRetriableError ) { + throw lastError; + } + if ( attempt < maxAttempts ) { + const delayMs = 1000 * 2 ** ( attempt - 1 ); + console.warn( + `[${ name }] Attempt ${ attempt }/${ maxAttempts } failed: ${ lastError.message }. Retrying in ${ delayMs }ms...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delayMs ) ); + } + } + } + throw lastError ?? new Error( `[${ name }] Failed after ${ maxAttempts } attempts` ); +} + +// 5xx and 429 are retriable; other 4xx are non-transient. +export function throwForHttpStatus( context: string, status: number, statusText?: string ): never { + const message = `${ context } failed with status code: ${ status }${ + statusText ? ` ${ statusText }` : '' + }`; + if ( status < 500 && status !== 429 ) { + throw new NonRetriableError( message ); + } + throw new Error( message ); +} From 6b9f9b01808cb366255cfa1e2e39f07814c184c1 Mon Sep 17 00:00:00 2001 From: Rahul Gavande Date: Fri, 22 May 2026 16:05:51 +0530 Subject: [PATCH 5/5] Keep connect timeout at 10s (matches undici default) The retry loop (3 attempts with backoff) provides 30s+ of total connect budget, which is enough to absorb the transient TLS handshake failures reported in STU-1749 without bumping the per-attempt cap. Keep the dispatcher and constant declared explicitly so the value can be tuned in one place if needed later. --- scripts/lib/with-retry.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/lib/with-retry.ts b/scripts/lib/with-retry.ts index d1010484b9..5bedb7a149 100644 --- a/scripts/lib/with-retry.ts +++ b/scripts/lib/with-retry.ts @@ -1,9 +1,8 @@ import { Agent } from 'undici'; export const DEFAULT_MAX_ATTEMPTS = 3; -export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; +export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; -// Node's default undici connect timeout (10s) is too aggressive on slow networks during postinstall. export const sharedDispatcher = new Agent( { connect: { timeout: DEFAULT_CONNECT_TIMEOUT_MS }, } );