Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 19 additions & 37 deletions scripts/download-language-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 )
Expand All @@ -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 );
Expand Down
66 changes: 39 additions & 27 deletions scripts/download-wp-server-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand All @@ -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 >;
Expand Down Expand Up @@ -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 );
Comment thread
gavande1 marked this conversation as resolved.
await fs.writeFile( zipPath, buffer );

if ( name === 'wp-cli' ) {
Expand Down
48 changes: 48 additions & 0 deletions scripts/lib/with-retry.ts
Original file line number Diff line number Diff line change
@@ -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 );
}