diff --git a/.github/workflows/e2e-versions.yml b/.github/workflows/e2e-versions.yml index 05ed82ba..1e8fbd45 100644 --- a/.github/workflows/e2e-versions.yml +++ b/.github/workflows/e2e-versions.yml @@ -83,6 +83,15 @@ jobs: - distribution: oracle os: ubuntu-latest version: 21 + - distribution: oracle-openjdk + os: macos-15-intel + version: 21 + - distribution: oracle-openjdk + os: windows-latest + version: 21 + - distribution: oracle-openjdk + os: ubuntu-latest + version: 21 - distribution: graalvm os: macos-latest version: 17.0.12 diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index 457a2b24..baa156d2 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -605,6 +605,31 @@ describe('setupJava', () => { expect(spyCoreSetOutput).not.toHaveBeenCalled(); }); + it('should not repeat version resolution when downloadTool fails', async () => { + mockJavaBase = new EmptyJavaBase({ + version: '11', + architecture: 'x86', + packageType: 'jdk', + checkLatest: false, + forceDownload: true + }); + const findPackageForDownload = jest.fn(async () => ({ + version: '11.0.9', + url: 'https://example.com/jdk.tar.gz' + })); + const downloadError = new Error('download failed'); + const downloadTool = jest.fn(async () => { + throw downloadError; + }); + mockJavaBase['findPackageForDownload'] = findPackageForDownload; + mockJavaBase['downloadTool'] = downloadTool; + + await expect(mockJavaBase.setupJava()).rejects.toBe(downloadError); + + expect(findPackageForDownload).toHaveBeenCalledTimes(1); + expect(downloadTool).toHaveBeenCalledTimes(1); + }); + it.each([ [ { diff --git a/__tests__/distributors/distribution-factory.test.ts b/__tests__/distributors/distribution-factory.test.ts index 4da7b23c..915e7a78 100644 --- a/__tests__/distributors/distribution-factory.test.ts +++ b/__tests__/distributors/distribution-factory.test.ts @@ -1,6 +1,38 @@ import {getJavaDistribution} from '../../src/distributions/distribution-factory.js'; +import {RetryingHttpClient} from '../../src/retrying-http-client.js'; describe('getJavaDistribution', () => { + it.each([ + 'adopt', + 'adopt-hotspot', + 'adopt-openj9', + 'temurin', + 'zulu', + 'liberica', + 'liberica-nik', + 'microsoft', + 'semeru', + 'corretto', + 'oracle', + 'dragonwell', + 'sapmachine', + 'graalvm', + 'graalvm-community', + 'jetbrains', + 'kona', + 'oracle-openjdk' + ])('uses the shared retrying HTTP client for %s', distributionName => { + const distribution = getJavaDistribution(distributionName, { + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + expect(distribution).not.toBeNull(); + expect(distribution!['http']).toBeInstanceOf(RetryingHttpClient); + }); + it("rejects java-package 'jdk+jmods' for non-Temurin distributions", () => { expect(() => getJavaDistribution('zulu', { diff --git a/__tests__/distributors/jetbrains-installer.test.ts b/__tests__/distributors/jetbrains-installer.test.ts index cd583e39..8d5e1a12 100644 --- a/__tests__/distributors/jetbrains-installer.test.ts +++ b/__tests__/distributors/jetbrains-installer.test.ts @@ -9,7 +9,9 @@ import { afterAll } from '@jest/globals'; import https from 'https'; -import {HttpClient} from '@actions/http-client'; +import {HttpClient, HttpClientResponse} from '@actions/http-client'; +import type {IncomingMessage} from 'http'; +import {Readable} from 'stream'; import manifestData from '../data/jetbrains.json' with {type: 'json'}; import os from 'os'; @@ -44,6 +46,18 @@ jest.unstable_mockModule('@actions/core', () => ({ const core = await import('@actions/core'); const {JetBrainsDistribution} = await import('../../src/distributions/jetbrains/installer.js'); +const {RetryingHttpClient} = await import('../../src/retrying-http-client.js'); + +function response( + statusCode: number, + body = '', + headers: IncomingMessage['headers'] = {} +): HttpClientResponse { + const message = Readable.from([Buffer.from(body)]) as IncomingMessage; + message.statusCode = statusCode; + message.headers = headers; + return new HttpClientResponse(message); +} describe('getAvailableVersions', () => { let spyHttpClient: any; @@ -95,6 +109,42 @@ describe('getAvailableVersions', () => { os.platform() === 'win32' ? manifestData.length : manifestData.length + 2; expect(availableVersions.length).toBe(length); }, 10_000); + + it('retries a GitHub rate limit using Retry-After', async () => { + spyHttpClient.mockRestore(); + const sleep = jest.fn(async () => undefined); + const requestRaw = jest + .spyOn(HttpClient.prototype, 'requestRaw') + .mockResolvedValueOnce(response(429, '', {'retry-after': '2'})) + .mockResolvedValueOnce(response(200, '[]')) + .mockResolvedValueOnce(response(200)) + .mockResolvedValueOnce(response(200)); + const distribution = new JetBrainsDistribution({ + version: '17', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + distribution['http'] = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + const availableVersions = await distribution['getAvailableVersions'](); + + expect(availableVersions).toHaveLength(2); + expect(requestRaw).toHaveBeenCalledTimes(4); + expect(requestRaw.mock.calls[0][0].options.path).toBe( + requestRaw.mock.calls[1][0].options.path + ); + expect(requestRaw.mock.calls[0][0].options.path).toContain( + '/repos/JetBrains/JetBrainsRuntime/releases' + ); + expect(sleep).toHaveBeenCalledWith(2000); + expect(core.info).toHaveBeenCalledWith( + 'Request attempt 1 of 4 failed (HTTP 429); retrying in 2000 ms' + ); + }); }); describe('findPackageForDownload', () => { diff --git a/__tests__/retrying-http-client.test.ts b/__tests__/retrying-http-client.test.ts new file mode 100644 index 00000000..b9fb63db --- /dev/null +++ b/__tests__/retrying-http-client.test.ts @@ -0,0 +1,251 @@ +import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'; +import type {IncomingMessage} from 'http'; + +jest.unstable_mockModule('@actions/core', () => ({ + info: jest.fn() +})); + +const core = await import('@actions/core'); +const httpm = await import('@actions/http-client'); +const {RetryingHttpClient, isRetryableNetworkError, parseRetryAfter} = + await import('../src/retrying-http-client.js'); + +function response( + statusCode: number, + retryAfter?: string +): httpm.HttpClientResponse { + return { + message: { + statusCode, + headers: retryAfter ? {'retry-after': retryAfter} : {} + } as IncomingMessage, + readBody: jest.fn(async () => '') + } as unknown as httpm.HttpClientResponse; +} + +describe('RetryingHttpClient', () => { + let request: ReturnType; + let sleep: jest.Mock<(delayMs: number) => Promise>; + + beforeEach(() => { + request = jest.spyOn(httpm.HttpClient.prototype, 'request'); + sleep = jest.fn(async () => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('uses exponential backoff with jitter for retryable responses', async () => { + request + .mockResolvedValueOnce(response(503)) + .mockResolvedValueOnce(response(502)) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + baseDelayMs: 1000, + maxDelayMs: 10000 + }); + + await expect(client.get('https://example.com')).resolves.toBeDefined(); + + expect(request).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenNthCalledWith(1, 500); + expect(sleep).toHaveBeenNthCalledWith(2, 1000); + expect(core.info).toHaveBeenNthCalledWith( + 1, + 'Request attempt 1 of 4 failed (HTTP 503); retrying in 500 ms' + ); + expect(core.info).toHaveBeenNthCalledWith( + 2, + 'Request attempt 2 of 4 failed (HTTP 502); retrying in 1000 ms' + ); + }); + + it('honors Retry-After delta-seconds over the client delay', async () => { + request + .mockResolvedValueOnce(response(429, '3')) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(3000); + }); + + it('honors Retry-After HTTP dates over the client delay', async () => { + const now = Date.parse('2026-07-29T00:00:00Z'); + request + .mockResolvedValueOnce(response(503, new Date(now + 5000).toUTCString())) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + now: () => now + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(5000); + }); + + it('caps Retry-After at the configured maximum delay', async () => { + request + .mockResolvedValueOnce(response(429, '60')) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + maxDelayMs: 10000 + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(10000); + }); + + it.each([429, 502, 503, 504, 522])( + 'retries HTTP %s responses', + async statusCode => { + request + .mockResolvedValueOnce(response(statusCode)) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + } + ); + + it.each(['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED'])( + 'retries network errors with code %s', + async code => { + request + .mockRejectedValueOnce(Object.assign(new Error(code), {code})) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + } + ); + + it('retries retryable aggregate network errors', async () => { + const aggregateError = Object.assign(new Error('connection failed'), { + errors: [Object.assign(new Error('timed out'), {code: 'ETIMEDOUT'})] + }); + request + .mockRejectedValueOnce(aggregateError) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(500); + }); + + it('does not retry non-retryable responses or network errors', async () => { + request.mockResolvedValueOnce(response(500)); + const client = new RetryingHttpClient('test', {sleep}); + + await expect(client.get('https://example.com')).resolves.toBeDefined(); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + + request.mockRejectedValueOnce( + Object.assign(new Error('certificate failed'), {code: 'CERT_HAS_EXPIRED'}) + ); + await expect(client.get('https://example.com')).rejects.toThrow( + 'certificate failed' + ); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('stops after the configured total attempt count', async () => { + request + .mockResolvedValueOnce(response(503)) + .mockResolvedValueOnce(response(503)); + const client = new RetryingHttpClient('test', { + maxAttempts: 2, + sleep, + random: () => 0 + }); + + const finalResponse = await client.get('https://example.com'); + + expect(finalResponse.message.statusCode).toBe(503); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('propagates the final network error after exhausting attempts', async () => { + const finalError = Object.assign(new Error('still unavailable'), { + code: 'ECONNREFUSED' + }); + request + .mockRejectedValueOnce( + Object.assign(new Error('unavailable'), {code: 'ECONNREFUSED'}) + ) + .mockRejectedValueOnce(finalError); + const client = new RetryingHttpClient('test', { + maxAttempts: 2, + sleep, + random: () => 0 + }); + + await expect(client.get('https://example.com')).rejects.toBe(finalError); + + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('does not retry write requests', async () => { + request.mockResolvedValueOnce(response(503)); + const client = new RetryingHttpClient('test', {sleep}); + + await client.post('https://example.com', '{}'); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); +}); + +describe('retry classification', () => { + it('parses valid Retry-After values and ignores invalid or past values', () => { + const now = Date.parse('2026-07-29T00:00:00Z'); + + expect(parseRetryAfter('7', now)).toBe(7000); + expect(parseRetryAfter(new Date(now + 3000).toUTCString(), now)).toBe(3000); + expect(parseRetryAfter(new Date(now - 3000).toUTCString(), now)).toBe( + undefined + ); + expect(parseRetryAfter('not-a-date', now)).toBe(undefined); + }); + + it('recognizes direct and nested retryable network error codes', () => { + expect(isRetryableNetworkError({code: 'ECONNRESET'})).toBe(true); + expect( + isRetryableNetworkError({errors: [{code: 'ENOTFOUND'}, {code: 'OTHER'}]}) + ).toBe(true); + expect(isRetryableNetworkError({code: 'CERT_HAS_EXPIRED'})).toBe(false); + expect(isRetryableNetworkError(new Error('unknown'))).toBe(false); + }); +}); diff --git a/dist/setup/index.js b/dist/setup/index.js index a5903a09..0dcc3972 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -129300,6 +129300,112 @@ function isProbablyGradleDaemonProblem(packageManager, error) { return message.startsWith('Tar failed with error: '); } +;// CONCATENATED MODULE: ./src/retrying-http-client.ts + + +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']); +class RetryingHttpClient extends lib_HttpClient { + maxAttempts; + baseDelayMs; + maxDelayMs; + sleep; + random; + now; + constructor(userAgent, retryOptions = {}) { + 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'); + } + } + async request(verb, requestUrl, data, headers) { + 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, retrying_http_client_getErrorMessage(error)); + await this.sleep(delayMs); + } + } + throw new Error('HTTP retry attempts exhausted unexpectedly'); + } + getDelayMs(failedAttempt, retryAfter) { + const exponentialDelay = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (failedAttempt - 1)); + const jitteredDelay = Math.floor(exponentialDelay / 2 + this.random() * (exponentialDelay / 2)); + const retryAfterDelay = retrying_http_client_parseRetryAfter(retryAfter, this.now()); + return Math.min(this.maxDelayMs, Math.max(jitteredDelay, retryAfterDelay ?? 0)); + } + logRetry(failedAttempt, delayMs, reason) { + info(`Request attempt ${failedAttempt} of ${this.maxAttempts} failed (${reason}); retrying in ${delayMs} ms`); + } +} +function retrying_http_client_parseRetryAfter(value, nowMs) { + 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; +} +function isRetryableNetworkError(error) { + 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) { + return typeof error === 'object' && error !== null; +} +function retrying_http_client_getErrorMessage(error) { + return error instanceof Error ? error.message : 'network error'; +} + ;// CONCATENATED MODULE: ./src/distributions/base-installer.ts @@ -129310,6 +129416,7 @@ function isProbablyGradleDaemonProblem(packageManager, error) { + class JavaBase { distribution; http; @@ -129325,10 +129432,7 @@ class JavaBase { verifySignaturePublicKey; constructor(distribution, installerOptions) { this.distribution = distribution; - this.http = new lib_HttpClient('actions/setup-java', undefined, { - allowRetries: true, - maxRetries: 3 - }); + this.http = new RetryingHttpClient('actions/setup-java'); ({ version: this.version, stable: this.stable, @@ -129355,107 +129459,22 @@ class JavaBase { } else { 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 && isDebug()) { - const consoleAny = console; - consoleAny._times?.clear?.(); - } - const javaRelease = await this.findPackageForDownload(this.version); - info(`Resolved latest version as ${javaRelease.version}`); - if (!this.forceDownload && - foundJava?.version === javaRelease.version) { - info(`Resolved Java ${foundJava.version} from tool-cache`); - } - else { - info('Trying to download...'); - foundJava = await this.downloadTool(javaRelease); - info(`Java ${foundJava.version} was downloaded`); - } - break; + try { + const javaRelease = await this.findPackageForDownload(this.version); + info(`Resolved latest version as ${javaRelease.version}`); + if (!this.forceDownload && foundJava?.version === javaRelease.version) { + info(`Resolved Java ${foundJava.version} from tool-cache`); } - catch (error) { - retries--; - // Check if error is retryable (including aggregate errors) - const isRetryable = (error instanceof 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) => 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 HTTPError) { - if (error.httpStatusCode === 403) { - core_error('HTTP 403: Permission denied or access restricted.'); - } - else if (error.httpStatusCode === 429) { - 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((acc, prop) => { - acc[prop] = error[prop]; - return acc; - }, {}) - }; - Object.entries(errorDetails).forEach(([key, value]) => { - core_debug(`"${key}": ${JSON.stringify(value)}`); - }); - } - throw error; + else { + info('Trying to download...'); + foundJava = await this.downloadTool(javaRelease); + info(`Java ${foundJava.version} was downloaded`); } } + catch (error) { + this.logSetupError(error); + throw error; + } } if (!foundJava) { throw new Error('Failed to resolve Java version'); @@ -129475,6 +129494,67 @@ class JavaBase { } return foundJava; } + logSetupError(error) { + const httpStatusCode = error instanceof HTTPError + ? error.httpStatusCode + : error instanceof lib_HttpClientError + ? error.statusCode + : undefined; + if (httpStatusCode) { + if (httpStatusCode === 403) { + core_error('HTTP 403: Permission denied or access restricted.'); + } + else if (httpStatusCode === 429) { + 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((acc, prop) => { + acc[prop] = error[prop]; + return acc; + }, {}) + }; + Object.entries(errorDetails).forEach(([key, value]) => { + core_debug(`"${key}": ${JSON.stringify(value)}`); + }); + } + } get toolcacheFolderName() { return `Java_${this.distribution}_${this.packageType}`; } diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index c00d21d2..f9cb55c4 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -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}`; } diff --git a/src/retrying-http-client.ts b/src/retrying-http-client.ts new file mode 100644 index 00000000..c9dae245 --- /dev/null +++ b/src/retrying-http-client.ts @@ -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; + 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; + 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 { + 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 { + return typeof error === 'object' && error !== null; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'network error'; +}