diff --git a/dist/ruff-action/index.js b/dist/ruff-action/index.js index f3f691d..1903987 100644 Binary files a/dist/ruff-action/index.js and b/dist/ruff-action/index.js differ diff --git a/dist/update-known-checksums/index.js b/dist/update-known-checksums/index.js index 71998fb..578e09e 100644 Binary files a/dist/update-known-checksums/index.js and b/dist/update-known-checksums/index.js differ diff --git a/src/download/checksum/update-known-checksums.ts b/src/download/checksum/update-known-checksums.ts index 61c0071..d19d475 100644 --- a/src/download/checksum/update-known-checksums.ts +++ b/src/download/checksum/update-known-checksums.ts @@ -1,5 +1,11 @@ import { promises as fs } from "node:fs"; import * as tc from "@actions/tool-cache"; +import { + isRetryableError, + NonRetryableError, + RetryableError, + withRetry, +} from "../../utils/retry"; import { KNOWN_CHECKSUMS } from "./known-checksums"; export async function updateChecksums( filePath: string, @@ -60,6 +66,27 @@ async function getOrDownloadChecksum( } async function downloadAssetContent(downloadUrl: string): Promise { - const downloadPath = await tc.downloadTool(downloadUrl); - return await fs.readFile(downloadPath, "utf8"); + return await withRetry( + async () => { + try { + const downloadPath = await tc.downloadTool(downloadUrl); + return await fs.readFile(downloadPath, "utf8"); + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `Failed to download checksum file: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `Failed to download checksum file: ${err.message}`, + err, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 30000 }, + "download checksum file", + ); } diff --git a/src/download/download-version.ts b/src/download/download-version.ts index ecd269d..2a49046 100644 --- a/src/download/download-version.ts +++ b/src/download/download-version.ts @@ -9,6 +9,12 @@ import * as pep440 from "@renovatebot/pep440"; import * as semver from "semver"; import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants"; import type { Architecture, Platform } from "../utils/platforms"; +import { + isRetryableError, + NonRetryableError, + RetryableError, + withRetry, +} from "../utils/retry"; import { validateChecksum } from "./checksum/checksum"; const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods); @@ -43,11 +49,29 @@ export async function downloadVersion( const downloadUrl = constructDownloadUrl(version, platform, arch); core.debug(`Downloading ruff from "${downloadUrl}" ...`); - const downloadPath = await tc.downloadTool( - downloadUrl, - undefined, - githubToken, + const downloadPath = await withRetry( + async () => { + try { + return await tc.downloadTool(downloadUrl, undefined, githubToken); + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `Failed to download ruff binary: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `Failed to download ruff binary: ${err.message}`, + err, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 60000 }, // 60 second timeout for downloads + "download ruff binary", ); + core.debug(`Downloaded ruff to "${downloadPath}"`); await validateChecksum(checkSum, downloadPath, arch, platform, version); @@ -134,30 +158,70 @@ export async function resolveVersion( } async function getAvailableVersions(githubToken: string): Promise { - try { - const octokit = new PaginatingOctokit({ - auth: githubToken, - }); - return await getReleaseTagNames(octokit); - } catch (err) { - if ((err as Error).message.includes("Bad credentials")) { - core.info( - "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", - ); - const octokit = new PaginatingOctokit(); - return await getReleaseTagNames(octokit); - } - throw err; - } + return await withRetry( + async () => { + try { + const octokit = new PaginatingOctokit({ + auth: githubToken, + }); + return await getReleaseTagNames(octokit); + } catch (err) { + const error = err as Error; + if (error.message.includes("Bad credentials")) { + core.info( + "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", + ); + const octokit = new PaginatingOctokit(); + return await getReleaseTagNames(octokit); + } + + if (isRetryableError(error)) { + throw new RetryableError( + `Failed to get available versions: ${error.message}`, + error, + ); + } else { + throw new NonRetryableError( + `Failed to get available versions: ${error.message}`, + error, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 30000 }, // 30 second timeout for API calls + "get available versions", + ); } async function getReleaseTagNames( octokit: InstanceType, ): Promise { - const response = await octokit.paginate(octokit.rest.repos.listReleases, { - owner: OWNER, - repo: REPO, - }); + const response = await withRetry( + async () => { + try { + return await octokit.paginate(octokit.rest.repos.listReleases, { + owner: OWNER, + repo: REPO, + }); + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `Failed to list GitHub releases: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `Failed to list GitHub releases: ${err.message}`, + err, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 30000 }, + "list GitHub releases", + ); + const releaseTagNames = response.map((release) => release.tag_name); if (releaseTagNames.length === 0) { throw Error( @@ -168,42 +232,82 @@ async function getReleaseTagNames( } async function getLatestVersion(githubToken: string) { - const octokit = new PaginatingOctokit({ - auth: githubToken, - }); + return await withRetry( + async () => { + const octokit = new PaginatingOctokit({ + auth: githubToken, + }); - let latestRelease: { tag_name: string } | undefined; - try { - latestRelease = await getLatestRelease(octokit); - } catch (err) { - if ((err as Error).message.includes("Bad credentials")) { - core.info( - "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", - ); - const octokit = new PaginatingOctokit(); - latestRelease = await getLatestRelease(octokit); - } else { - core.error( - "Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.", - ); - throw err; - } - } + let latestRelease: { tag_name: string } | undefined; + try { + latestRelease = await getLatestRelease(octokit); + } catch (err) { + const error = err as Error; + if (error.message.includes("Bad credentials")) { + core.info( + "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", + ); + const octokit = new PaginatingOctokit(); + latestRelease = await getLatestRelease(octokit); + } else { + core.error( + "Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.", + ); - if (!latestRelease) { - throw new Error("Could not determine latest release."); - } - return latestRelease.tag_name; + if (isRetryableError(error)) { + throw new RetryableError( + `Failed to get latest version: ${error.message}`, + error, + ); + } else { + throw new NonRetryableError( + `Failed to get latest version: ${error.message}`, + error, + ); + } + } + } + + if (!latestRelease) { + throw new Error("Could not determine latest release."); + } + return latestRelease.tag_name; + }, + { maxRetries: 3, timeoutMs: 30000 }, + "get latest version", + ); } async function getLatestRelease( octokit: InstanceType, ) { - const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({ - owner: OWNER, - repo: REPO, - }); - return latestRelease; + return await withRetry( + async () => { + try { + const { data: latestRelease } = + await octokit.rest.repos.getLatestRelease({ + owner: OWNER, + repo: REPO, + }); + return latestRelease; + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `Failed to get latest release: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `Failed to get latest release: ${err.message}`, + err, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 30000 }, + "get latest release", + ); } function maxSatisfying( diff --git a/src/update-known-checksums.ts b/src/update-known-checksums.ts index 2b47d54..3095aa9 100644 --- a/src/update-known-checksums.ts +++ b/src/update-known-checksums.ts @@ -5,6 +5,12 @@ import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; import * as semver from "semver"; import { updateChecksums } from "./download/checksum/update-known-checksums"; import { OWNER, REPO } from "./utils/constants"; +import { + isRetryableError, + NonRetryableError, + RetryableError, + withRetry, +} from "./utils/retry"; const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods); @@ -12,12 +18,33 @@ async function run(): Promise { const checksumFilePath = process.argv.slice(2)[0]; const github_token = process.argv.slice(2)[1]; - const octokit = new PaginatingOctokit({ auth: github_token }); + const response = await withRetry( + async () => { + try { + const octokit = new PaginatingOctokit({ auth: github_token }); + return await octokit.paginate(octokit.rest.repos.listReleases, { + owner: OWNER, + repo: REPO, + }); + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `Failed to list releases for checksum update: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `Failed to list releases for checksum update: ${err.message}`, + err, + ); + } + } + }, + { maxRetries: 3, timeoutMs: 60000 }, + "list releases for checksum update", + ); - const response = await octokit.paginate(octokit.rest.repos.listReleases, { - owner: OWNER, - repo: REPO, - }); const downloadUrls: string[] = response.flatMap((release) => release.assets .filter((asset) => asset.name.endsWith(".sha256")) diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..b313669 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,176 @@ +import * as core from "@actions/core"; + +export interface RetryOptions { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; + timeoutMs?: number; +} + +export const DEFAULT_RETRY_OPTIONS: RetryOptions = { + backoffMultiplier: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + maxRetries: 3, + timeoutMs: 30000, // 30 second timeout +}; + +export class RetryableError extends Error { + constructor( + message: string, + public readonly cause?: Error, + ) { + super(message); + this.name = "RetryableError"; + } +} + +export class NonRetryableError extends Error { + constructor( + message: string, + public readonly cause?: Error, + ) { + super(message); + this.name = "NonRetryableError"; + } +} + +export async function withRetry( + operation: () => Promise, + options: Partial = {}, + operationName = "operation", +): Promise { + const opts = { ...DEFAULT_RETRY_OPTIONS, ...options }; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = Math.min( + opts.initialDelayMs * opts.backoffMultiplier ** (attempt - 1), + opts.maxDelayMs, + ); + core.info( + `Retrying ${operationName} (attempt ${attempt + 1}/${opts.maxRetries + 1}) after ${delay}ms delay...`, + ); + await sleep(delay); + } + + // Wrap operation with timeout if specified + if (opts.timeoutMs) { + return await withTimeout(operation(), opts.timeoutMs, operationName); + } else { + return await operation(); + } + } catch (error) { + lastError = error as Error; + + // Don't retry on non-retryable errors + if (lastError instanceof NonRetryableError) { + core.debug( + `Non-retryable error in ${operationName}: ${lastError.message}`, + ); + throw lastError.cause || lastError; + } + + // Log the error for debugging + core.debug( + `Attempt ${attempt + 1} failed for ${operationName}: ${lastError.message}`, + ); + + // If this was the last attempt, throw the error + if (attempt === opts.maxRetries) { + core.error( + `${operationName} failed after ${opts.maxRetries + 1} attempts. Last error: ${lastError.message}`, + ); + throw lastError; + } + } + } + + throw lastError || new Error(`${operationName} failed for unknown reason`); +} + +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName = "operation", +): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new RetryableError(`${operationName} timed out after ${timeoutMs}ms`), + ); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function isRetryableError(error: Error): boolean { + const message = error.message.toLowerCase(); + + // Network-related errors that should be retried + const retryableMessages = [ + "connect timeout", + "connection timeout", + "timeout", + "econnreset", + "econnrefused", + "enotfound", + "network error", + "request timeout", + "socket timeout", + "fetch failed", + "connect etimedout", + ]; + + // HTTP status codes that should be retried + const retryableStatusCodes = [408, 429, 500, 502, 503, 504]; + + // Check for retryable messages + if (retryableMessages.some((msg) => message.includes(msg))) { + return true; + } + + // Check for HTTP status codes in error message + const statusMatch = message.match(/status.*?(\d{3})/); + if (statusMatch) { + const statusCode = parseInt(statusMatch[1], 10); + if (retryableStatusCodes.includes(statusCode)) { + return true; + } + } + + return false; +} + +export function wrapWithRetryLogic( + operation: () => Promise, + operationName: string, + options: Partial = {}, +): () => Promise { + return async () => { + try { + return await withRetry(operation, options, operationName); + } catch (error) { + const err = error as Error; + if (isRetryableError(err)) { + throw new RetryableError( + `${operationName} failed: ${err.message}`, + err, + ); + } else { + throw new NonRetryableError( + `${operationName} failed: ${err.message}`, + err, + ); + } + } + }; +}