From 0e7e01552a34f46cb9e7ff52908af440a0c80e97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:56:51 +0000 Subject: [PATCH] Add retry mechanisms for network operations to fix Connect Timeout Error Co-authored-by: eifinger <1481961+eifinger@users.noreply.github.com> --- dist/ruff-action/index.js | Bin 1546555 -> 1555537 bytes dist/update-known-checksums/index.js | Bin 1483420 -> 1490790 bytes .../checksum/update-known-checksums.ts | 31 ++- src/download/download-version.ts | 208 +++++++++++++----- src/update-known-checksums.ts | 37 +++- src/utils/retry.ts | 176 +++++++++++++++ 6 files changed, 393 insertions(+), 59 deletions(-) create mode 100644 src/utils/retry.ts diff --git a/dist/ruff-action/index.js b/dist/ruff-action/index.js index f3f691ddc2c673faf87dfc406752f9dcc43f4d70..1903987443e65e53282770b17e3112724a5be726 100644 GIT binary patch delta 5866 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 N_}jQ}y?s-9?tdtf*QWpg delta 428 zcma)%&nts*9LIaUwrATj`@!d#Url?alttz;Whgm1IN(eUU!}Iuvxi!&luvO~4xfXX zgM;KIc@7Tz31r%m17)iTaN?DTVx(MBreS`?@<-tX#1qjj$ zll|iCjMBkX>M5G1t$HTW#}mY9W1NX}UxYAaO4au;oiM9NQ(epZJ!hkxXvXkUzSfxZxsBi+Eq|81x16t^F-Q%T^d7qPp3Mgt7_>1Qb?S78DlT4%5@^xa~}xGqsS~ z6vHGw2#RU&MDfA@z@Wx`GU|hg@e<>M_+p|@z8U=s)bE@#GwrnHBC*MKJLkKc?|k3y zyPSFdt24jdd79&lDU4?3%zL1*Zb@bOBIg>CC1 zO;^GXckkG>ExCNC5`LtA6Regj1F2;FA;9ch*0B>p^<+a(Gw$lbu6#a(bO0y*VlOK8`KRv{E6|^wG0OY1XY1u30^w#|Id9 zQ~@FS{_$@&*d=bYYUV9MtD@BXXgj{>33Jc18bW7c-t7gb|pN`WAww7 z@Kf}&TT(wAyc&+sv7e<7-ElQMMj!iGQtH*yL;qLcPJ7P1y=U_}u>oFrtP)I6*0dTC zEEO}VeJ?RQ>uqz#7~bbjJe<$oC7h4>;k@fEk>z7o!@XT1-ENM^`@5D$FN8moYi-7( zX@oBnP0J?np#Pc(`wtx4ck;;G!t8;$+0zTp9h*D++{_7}zhdeclt_g<1wJT%=Kl_m zYq>cAPnW}eYVfq?C-Ai{HJHnmT72Fs7DvoQU1Mt77E>Y3GynjaY0*O)T)~ z>e}f|5rpiWn`2H^}hw705iFFylJPUx-AJK!ku1h;bPd zkH_zbq!{B6o17sC0?GPP9@_}wX>;$3C036VwyxSXE);DrD3S6X_DnNFNxV_!ecI$C zSVs5VlGf6_y4=0OlYasw>e4sE^}pWiUC1zyg2tn&or@QA0~`R#f(S+vVv9D!qlpQm zcv`ohWRyFN+v38!KKMeI-WZZYIL%?A8;j5meb+HxRvRidS3G5tC|R9ijDeFOwj4!j z*2Wg7;kH)`1o0o>%rx6>P)rOYk8x4q6xM5>f$Vu6Qoj*LUV^+lLNExBBG&Y*jx%i& z0yo1?Naj2G+ow|B95lO_M66|tbM^mgaFKjX>8#SY={ver0qlvtvZxG)zGk+ZBG<{PyPBwgS0RuuS`H1 zwdzH>(H2cM?r`n(t0&jj(F@&d!s%V-Zx|Ov=+##nFsH{?O6$hqBv7M~opX)meK>>9 zmlhWRH6f}_$)l-#ZVCjEg0f-;W0}&5u7EvfVUxpI*h43%tP;K%v&_pT7>{z6S#B87 z<~R?a)6;Ev$8hl?G!Y! z+*=do`ve4THgFm7YI1vnnBPjqk^%E8;7BE=k&dRIoA9Rc7 z-D;$p6vu=TKsGbdeNvPK`u0~{BNC$Pe(V{gFXG<*f`wngnyoOlV7uvXD5gAvV8-zKkW7I434ZXNX3kB3KT@dIhN1~{F;cG|r@dFQME~(~|ERcdux2#xpEv?c2Ds8YcSH;(%po2LnqI9F+4WSj1e-++3lnU2 zSk1z)kqN9DrkyoQMqtqp#}vo&xNFQD3%^{#kOCd{C8o9n4Y30yi)}6ECYq1+ry znGAr#C7u&t0Dm8vn>&V}7TP6(YMPmWo4~#M`UIDInoEJZwPuHj!&~|A}@1(%#gSx?!}^Hv+Jo(^WqsNW&tZofhnVUdsQMCv@Y}m~*^T~5Dn3r9JOfcYr6ZGo`R*ctYzaORVD)K7& z{(I7jcRr9-C;T*PAMYDIxv2xwA1C|9==ir%-}PkwFn@8C7N!>F7M2#)7Pc1l7LFFq7OocV7M>Q~7QPn#7J(MQ7NIS|fz{Jv zdxbT&FR2#(!an_8oiN+ { - 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, + ); + } + } + }; +}