Consolidate JDK metadata retry handling (#1162)

* Consolidate JDK metadata retries

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06

* Expand distribution retry coverage

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06

---------

Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06
This commit is contained in:
Bruno Borges
2026-07-29 00:09:37 -04:00
committed by GitHub
parent e1ce3a3428
commit 5894ef6b27
8 changed files with 792 additions and 213 deletions
+76 -110
View File
@@ -15,6 +15,7 @@ import {
JavaInstallerResults
} from './base-models.js';
import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js';
import {RetryingHttpClient} from '../retrying-http-client.js';
import os from 'os';
export abstract class JavaBase {
@@ -34,10 +35,7 @@ export abstract class JavaBase {
protected distribution: string,
installerOptions: JavaInstallerOptions
) {
this.http = new httpm.HttpClient('actions/setup-java', undefined, {
allowRetries: true,
maxRetries: 3
});
this.http = new RetryingHttpClient('actions/setup-java');
({
version: this.version,
@@ -75,113 +73,19 @@ export abstract class JavaBase {
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
} else {
core.info('Trying to resolve the latest version from remote');
const MAX_RETRIES = 4;
const RETRY_DELAY_MS = 2000;
const retryableCodes = [
'ETIMEDOUT',
'ECONNRESET',
'ENOTFOUND',
'ECONNREFUSED'
];
let retries = MAX_RETRIES;
while (retries > 0) {
try {
// Clear console timers before each attempt to prevent conflicts
if (retries < MAX_RETRIES && core.isDebug()) {
const consoleAny = console as any;
consoleAny._times?.clear?.();
}
const javaRelease = await this.findPackageForDownload(this.version);
core.info(`Resolved latest version as ${javaRelease.version}`);
if (
!this.forceDownload &&
foundJava?.version === javaRelease.version
) {
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
} else {
core.info('Trying to download...');
foundJava = await this.downloadTool(javaRelease);
core.info(`Java ${foundJava.version} was downloaded`);
}
break;
} catch (error: any) {
retries--;
// Check if error is retryable (including aggregate errors)
const isRetryable =
(error instanceof tc.HTTPError &&
error.httpStatusCode &&
[429, 502, 503, 504, 522].includes(error.httpStatusCode)) ||
retryableCodes.includes(error?.code) ||
(error?.errors &&
Array.isArray(error.errors) &&
error.errors.some((err: any) =>
retryableCodes.includes(err?.code)
));
if (retries > 0 && isRetryable) {
core.debug(
`Attempt failed due to network or timeout issues, initiating retry... (${retries} attempts left)`
);
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
continue;
}
if (error instanceof tc.HTTPError) {
if (error.httpStatusCode === 403) {
core.error('HTTP 403: Permission denied or access restricted.');
} else if (error.httpStatusCode === 429) {
core.warning(
'HTTP 429: Rate limit exceeded. Please retry later.'
);
} else {
core.error(`HTTP ${error.httpStatusCode}: ${error.message}`);
}
} else if (error && error.errors && Array.isArray(error.errors)) {
core.error(
`Java setup failed due to network or configuration error(s)`
);
if (error instanceof Error && error.stack) {
core.debug(error.stack);
}
for (const err of error.errors) {
const endpoint = err?.address || err?.hostname || '';
const port = err?.port ? `:${err.port}` : '';
const message = err?.message || 'Aggregate error';
const endpointInfo = !message.includes(endpoint)
? ` ${endpoint}${port}`
: '';
const localInfo =
err.localAddress && err.localPort
? ` - Local (${err.localAddress}:${err.localPort})`
: '';
const logMessage = `${message}${endpointInfo}${localInfo}`;
core.error(logMessage);
core.debug(`${err.stack || err.message}`);
Object.entries(err).forEach(([key, value]) => {
core.debug(`"${key}": ${JSON.stringify(value)}`);
});
}
} else {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
core.error(`Java setup process failed due to: ${message}`);
if (typeof error?.code === 'string') {
core.debug(error.stack);
}
const errorDetails = {
name: error.name,
message: error.message,
...Object.getOwnPropertyNames(error)
.filter(prop => !['name', 'message', 'stack'].includes(prop))
.reduce<{[key: string]: any}>((acc, prop) => {
acc[prop] = error[prop];
return acc;
}, {})
};
Object.entries(errorDetails).forEach(([key, value]) => {
core.debug(`"${key}": ${JSON.stringify(value)}`);
});
}
throw error;
try {
const javaRelease = await this.findPackageForDownload(this.version);
core.info(`Resolved latest version as ${javaRelease.version}`);
if (!this.forceDownload && foundJava?.version === javaRelease.version) {
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
} else {
core.info('Trying to download...');
foundJava = await this.downloadTool(javaRelease);
core.info(`Java ${foundJava.version} was downloaded`);
}
} catch (error: any) {
this.logSetupError(error);
throw error;
}
}
if (!foundJava) {
@@ -209,6 +113,68 @@ export abstract class JavaBase {
return foundJava;
}
private logSetupError(error: any): void {
const httpStatusCode =
error instanceof tc.HTTPError
? error.httpStatusCode
: error instanceof httpm.HttpClientError
? error.statusCode
: undefined;
if (httpStatusCode) {
if (httpStatusCode === 403) {
core.error('HTTP 403: Permission denied or access restricted.');
} else if (httpStatusCode === 429) {
core.warning('HTTP 429: Rate limit exceeded. Please retry later.');
} else {
core.error(`HTTP ${httpStatusCode}: ${error.message}`);
}
} else if (error && error.errors && Array.isArray(error.errors)) {
core.error(`Java setup failed due to network or configuration error(s)`);
if (error instanceof Error && error.stack) {
core.debug(error.stack);
}
for (const err of error.errors) {
const endpoint = err?.address || err?.hostname || '';
const port = err?.port ? `:${err.port}` : '';
const message = err?.message || 'Aggregate error';
const endpointInfo = !message.includes(endpoint)
? ` ${endpoint}${port}`
: '';
const localInfo =
err.localAddress && err.localPort
? ` - Local (${err.localAddress}:${err.localPort})`
: '';
const logMessage = `${message}${endpointInfo}${localInfo}`;
core.error(logMessage);
core.debug(`${err.stack || err.message}`);
Object.entries(err).forEach(([key, value]) => {
core.debug(`"${key}": ${JSON.stringify(value)}`);
});
}
} else {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
core.error(`Java setup process failed due to: ${message}`);
if (typeof error?.code === 'string') {
core.debug(error.stack);
}
const errorDetails = {
name: error.name,
message: error.message,
...Object.getOwnPropertyNames(error)
.filter(prop => !['name', 'message', 'stack'].includes(prop))
.reduce<{[key: string]: any}>((acc, prop) => {
acc[prop] = error[prop];
return acc;
}, {})
};
Object.entries(errorDetails).forEach(([key, value]) => {
core.debug(`"${key}": ${JSON.stringify(value)}`);
});
}
}
protected get toolcacheFolderName(): string {
return `Java_${this.distribution}_${this.packageType}`;
}
+166
View File
@@ -0,0 +1,166 @@
import * as core from '@actions/core';
import * as httpm from '@actions/http-client';
import type {OutgoingHttpHeaders} from 'http';
const RETRYABLE_HTTP_STATUS_CODES = new Set([429, 502, 503, 504, 522]);
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
'ETIMEDOUT',
'ECONNRESET',
'ENOTFOUND',
'ECONNREFUSED'
]);
const RETRYABLE_HTTP_VERBS = new Set(['OPTIONS', 'GET', 'DELETE', 'HEAD']);
export interface HttpRetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
sleep?: (delayMs: number) => Promise<void>;
random?: () => number;
now?: () => number;
}
export class RetryingHttpClient extends httpm.HttpClient {
private readonly maxAttempts: number;
private readonly baseDelayMs: number;
private readonly maxDelayMs: number;
private readonly sleep: (delayMs: number) => Promise<void>;
private readonly random: () => number;
private readonly now: () => number;
constructor(userAgent?: string, retryOptions: HttpRetryOptions = {}) {
super(userAgent, undefined, {allowRetries: false});
this.maxAttempts = retryOptions.maxAttempts ?? 4;
this.baseDelayMs = retryOptions.baseDelayMs ?? 1000;
this.maxDelayMs = retryOptions.maxDelayMs ?? 10000;
this.sleep =
retryOptions.sleep ??
(delayMs => new Promise(resolve => setTimeout(resolve, delayMs)));
this.random = retryOptions.random ?? Math.random;
this.now = retryOptions.now ?? Date.now;
if (this.maxAttempts < 1) {
throw new Error('maxAttempts must be at least 1');
}
if (this.baseDelayMs < 0 || this.maxDelayMs < this.baseDelayMs) {
throw new Error(
'baseDelayMs must be non-negative and no greater than maxDelayMs'
);
}
}
public override async request(
verb: string,
requestUrl: string,
data: string | NodeJS.ReadableStream | null,
headers?: OutgoingHttpHeaders
): Promise<httpm.HttpClientResponse> {
if (!RETRYABLE_HTTP_VERBS.has(verb)) {
return super.request(verb, requestUrl, data, headers);
}
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
const response = await super.request(verb, requestUrl, data, headers);
const statusCode = response.message.statusCode;
if (
!statusCode ||
!RETRYABLE_HTTP_STATUS_CODES.has(statusCode) ||
attempt === this.maxAttempts
) {
return response;
}
const delayMs = this.getDelayMs(
attempt,
response.message.headers['retry-after']
);
await response.readBody();
this.logRetry(attempt, delayMs, `HTTP ${statusCode}`);
await this.sleep(delayMs);
} catch (error) {
if (!isRetryableNetworkError(error) || attempt === this.maxAttempts) {
throw error;
}
const delayMs = this.getDelayMs(attempt);
this.logRetry(attempt, delayMs, getErrorMessage(error));
await this.sleep(delayMs);
}
}
throw new Error('HTTP retry attempts exhausted unexpectedly');
}
private getDelayMs(
failedAttempt: number,
retryAfter?: string | string[]
): number {
const exponentialDelay = Math.min(
this.maxDelayMs,
this.baseDelayMs * 2 ** (failedAttempt - 1)
);
const jitteredDelay = Math.floor(
exponentialDelay / 2 + this.random() * (exponentialDelay / 2)
);
const retryAfterDelay = parseRetryAfter(retryAfter, this.now());
return Math.min(
this.maxDelayMs,
Math.max(jitteredDelay, retryAfterDelay ?? 0)
);
}
private logRetry(
failedAttempt: number,
delayMs: number,
reason: string
): void {
core.info(
`Request attempt ${failedAttempt} of ${this.maxAttempts} failed (${reason}); retrying in ${delayMs} ms`
);
}
}
export function parseRetryAfter(
value: string | string[] | undefined,
nowMs: number
): number | undefined {
const retryAfter = Array.isArray(value) ? value[0] : value;
if (!retryAfter) {
return undefined;
}
if (/^\d+$/.test(retryAfter.trim())) {
return Number(retryAfter) * 1000;
}
const retryAt = Date.parse(retryAfter);
if (Number.isNaN(retryAt) || retryAt <= nowMs) {
return undefined;
}
return retryAt - nowMs;
}
export function isRetryableNetworkError(error: unknown): boolean {
if (!isErrorRecord(error)) {
return false;
}
if (
typeof error.code === 'string' &&
RETRYABLE_NETWORK_ERROR_CODES.has(error.code)
) {
return true;
}
return (
Array.isArray(error.errors) &&
error.errors.some(nestedError => isRetryableNetworkError(nestedError))
);
}
function isErrorRecord(error: unknown): error is Record<string, unknown> {
return typeof error === 'object' && error !== null;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'network error';
}