mirror of
https://github.com/actions/setup-java.git
synced 2026-08-01 16:22:58 +00:00
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:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user