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 257705fd1d..bcad4c0948 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -9,6 +9,20 @@ import fs from 'fs-extra'; import { z } from 'zod'; import { extractZip } from '@studio/common/lib/extract-zip'; import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../apps/studio/src/constants'; +import { sharedDispatcher, throwForHttpStatus, withRetry } from './lib/with-retry'; + +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' ); const PHPMYADMIN_PATCH_FILES_PATH = path.join( import.meta.dirname, '..', 'apps', 'cli', 'php' ); @@ -26,32 +40,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', + }; + + // 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 }`; + } - const response = await fetch( `https://api.github.com/repos/${ repo }/releases/latest`, { - headers, - signal: AbortSignal.timeout( 5000 ), - } ); + const response = await fetch( `https://api.github.com/repos/${ repo }/releases/latest`, { + headers, + dispatcher: sharedDispatcher, + } as RequestInit ); - if ( ! response.ok ) { - throw new Error( `GitHub API request failed: ${ response.status } ${ response.statusText }` ); - } + if ( ! response.ok ) { + throwForHttpStatus( 'GitHub API request', response.status, response.statusText ); + } - const rawResponse: unknown = await response.json(); + const rawResponse: unknown = await response.json(); - return partialGithubReleaseSchema.parse( rawResponse ); + return partialGithubReleaseSchema.parse( rawResponse ); + } ); } type MaybePromise< T > = T | Promise< T >; @@ -117,11 +133,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' ) { diff --git a/scripts/lib/with-retry.ts b/scripts/lib/with-retry.ts new file mode 100644 index 0000000000..5bedb7a149 --- /dev/null +++ b/scripts/lib/with-retry.ts @@ -0,0 +1,48 @@ +import { Agent } from 'undici'; + +export const DEFAULT_MAX_ATTEMPTS = 3; +export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + +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 ); +}