Add retry mechanisms for network operations to fix Connect Timeout Error

Co-authored-by: eifinger <1481961+eifinger@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-14 08:56:51 +00:00
co-authored by eifinger
parent 03f689c6c5
commit 0e7e01552a
6 changed files with 393 additions and 59 deletions
Generated Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -1,5 +1,11 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import * as tc from "@actions/tool-cache"; import * as tc from "@actions/tool-cache";
import {
isRetryableError,
NonRetryableError,
RetryableError,
withRetry,
} from "../../utils/retry";
import { KNOWN_CHECKSUMS } from "./known-checksums"; import { KNOWN_CHECKSUMS } from "./known-checksums";
export async function updateChecksums( export async function updateChecksums(
filePath: string, filePath: string,
@@ -60,6 +66,27 @@ async function getOrDownloadChecksum(
} }
async function downloadAssetContent(downloadUrl: string): Promise<string> { async function downloadAssetContent(downloadUrl: string): Promise<string> {
return await withRetry(
async () => {
try {
const downloadPath = await tc.downloadTool(downloadUrl); const downloadPath = await tc.downloadTool(downloadUrl);
return await fs.readFile(downloadPath, "utf8"); 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",
);
} }
+114 -10
View File
@@ -9,6 +9,12 @@ import * as pep440 from "@renovatebot/pep440";
import * as semver from "semver"; import * as semver from "semver";
import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants"; import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants";
import type { Architecture, Platform } from "../utils/platforms"; import type { Architecture, Platform } from "../utils/platforms";
import {
isRetryableError,
NonRetryableError,
RetryableError,
withRetry,
} from "../utils/retry";
import { validateChecksum } from "./checksum/checksum"; import { validateChecksum } from "./checksum/checksum";
const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods); const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods);
@@ -43,11 +49,29 @@ export async function downloadVersion(
const downloadUrl = constructDownloadUrl(version, platform, arch); const downloadUrl = constructDownloadUrl(version, platform, arch);
core.debug(`Downloading ruff from "${downloadUrl}" ...`); core.debug(`Downloading ruff from "${downloadUrl}" ...`);
const downloadPath = await tc.downloadTool( const downloadPath = await withRetry(
downloadUrl, async () => {
undefined, try {
githubToken, 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}"`); core.debug(`Downloaded ruff to "${downloadPath}"`);
await validateChecksum(checkSum, downloadPath, arch, platform, version); await validateChecksum(checkSum, downloadPath, arch, platform, version);
@@ -134,30 +158,70 @@ export async function resolveVersion(
} }
async function getAvailableVersions(githubToken: string): Promise<string[]> { async function getAvailableVersions(githubToken: string): Promise<string[]> {
return await withRetry(
async () => {
try { try {
const octokit = new PaginatingOctokit({ const octokit = new PaginatingOctokit({
auth: githubToken, auth: githubToken,
}); });
return await getReleaseTagNames(octokit); return await getReleaseTagNames(octokit);
} catch (err) { } catch (err) {
if ((err as Error).message.includes("Bad credentials")) { const error = err as Error;
if (error.message.includes("Bad credentials")) {
core.info( core.info(
"No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.",
); );
const octokit = new PaginatingOctokit(); const octokit = new PaginatingOctokit();
return await getReleaseTagNames(octokit); return await getReleaseTagNames(octokit);
} }
throw err;
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( async function getReleaseTagNames(
octokit: InstanceType<typeof PaginatingOctokit>, octokit: InstanceType<typeof PaginatingOctokit>,
): Promise<string[]> { ): Promise<string[]> {
const response = await octokit.paginate(octokit.rest.repos.listReleases, { const response = await withRetry(
async () => {
try {
return await octokit.paginate(octokit.rest.repos.listReleases, {
owner: OWNER, owner: OWNER,
repo: REPO, 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); const releaseTagNames = response.map((release) => release.tag_name);
if (releaseTagNames.length === 0) { if (releaseTagNames.length === 0) {
throw Error( throw Error(
@@ -168,6 +232,8 @@ async function getReleaseTagNames(
} }
async function getLatestVersion(githubToken: string) { async function getLatestVersion(githubToken: string) {
return await withRetry(
async () => {
const octokit = new PaginatingOctokit({ const octokit = new PaginatingOctokit({
auth: githubToken, auth: githubToken,
}); });
@@ -176,7 +242,8 @@ async function getLatestVersion(githubToken: string) {
try { try {
latestRelease = await getLatestRelease(octokit); latestRelease = await getLatestRelease(octokit);
} catch (err) { } catch (err) {
if ((err as Error).message.includes("Bad credentials")) { const error = err as Error;
if (error.message.includes("Bad credentials")) {
core.info( core.info(
"No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.",
); );
@@ -186,7 +253,18 @@ async function getLatestVersion(githubToken: string) {
core.error( core.error(
"Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.", "Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.",
); );
throw err;
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,
);
}
} }
} }
@@ -194,16 +272,42 @@ async function getLatestVersion(githubToken: string) {
throw new Error("Could not determine latest release."); throw new Error("Could not determine latest release.");
} }
return latestRelease.tag_name; return latestRelease.tag_name;
},
{ maxRetries: 3, timeoutMs: 30000 },
"get latest version",
);
} }
async function getLatestRelease( async function getLatestRelease(
octokit: InstanceType<typeof PaginatingOctokit>, octokit: InstanceType<typeof PaginatingOctokit>,
) { ) {
const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({ return await withRetry(
async () => {
try {
const { data: latestRelease } =
await octokit.rest.repos.getLatestRelease({
owner: OWNER, owner: OWNER,
repo: REPO, repo: REPO,
}); });
return latestRelease; 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( function maxSatisfying(
+29 -2
View File
@@ -5,6 +5,12 @@ import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import * as semver from "semver"; import * as semver from "semver";
import { updateChecksums } from "./download/checksum/update-known-checksums"; import { updateChecksums } from "./download/checksum/update-known-checksums";
import { OWNER, REPO } from "./utils/constants"; import { OWNER, REPO } from "./utils/constants";
import {
isRetryableError,
NonRetryableError,
RetryableError,
withRetry,
} from "./utils/retry";
const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods); const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods);
@@ -12,12 +18,33 @@ async function run(): Promise<void> {
const checksumFilePath = process.argv.slice(2)[0]; const checksumFilePath = process.argv.slice(2)[0];
const github_token = process.argv.slice(2)[1]; const github_token = process.argv.slice(2)[1];
const response = await withRetry(
async () => {
try {
const octokit = new PaginatingOctokit({ auth: github_token }); const octokit = new PaginatingOctokit({ auth: github_token });
return await octokit.paginate(octokit.rest.repos.listReleases, {
const response = await octokit.paginate(octokit.rest.repos.listReleases, {
owner: OWNER, owner: OWNER,
repo: REPO, 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 downloadUrls: string[] = response.flatMap((release) => const downloadUrls: string[] = response.flatMap((release) =>
release.assets release.assets
.filter((asset) => asset.name.endsWith(".sha256")) .filter((asset) => asset.name.endsWith(".sha256"))
+176
View File
@@ -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<T>(
operation: () => Promise<T>,
options: Partial<RetryOptions> = {},
operationName = "operation",
): Promise<T> {
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<T>(
promise: Promise<T>,
timeoutMs: number,
operationName = "operation",
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new RetryableError(`${operationName} timed out after ${timeoutMs}ms`),
);
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]);
}
function sleep(ms: number): Promise<void> {
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<T>(
operation: () => Promise<T>,
operationName: string,
options: Partial<RetryOptions> = {},
): () => Promise<T> {
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,
);
}
}
};
}