mirror of
https://github.com/actions/setup-java.git
synced 2026-07-31 14:41:36 +08: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:
parent
e1ce3a3428
commit
5894ef6b27
9
.github/workflows/e2e-versions.yml
vendored
9
.github/workflows/e2e-versions.yml
vendored
@ -83,6 +83,15 @@ jobs:
|
|||||||
- distribution: oracle
|
- distribution: oracle
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
version: 21
|
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
|
- distribution: graalvm
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
version: 17.0.12
|
version: 17.0.12
|
||||||
|
|||||||
@ -605,6 +605,31 @@ describe('setupJava', () => {
|
|||||||
expect(spyCoreSetOutput).not.toHaveBeenCalled();
|
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([
|
it.each([
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,38 @@
|
|||||||
import {getJavaDistribution} from '../../src/distributions/distribution-factory.js';
|
import {getJavaDistribution} from '../../src/distributions/distribution-factory.js';
|
||||||
|
import {RetryingHttpClient} from '../../src/retrying-http-client.js';
|
||||||
|
|
||||||
describe('getJavaDistribution', () => {
|
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", () => {
|
it("rejects java-package 'jdk+jmods' for non-Temurin distributions", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getJavaDistribution('zulu', {
|
getJavaDistribution('zulu', {
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import {
|
|||||||
afterAll
|
afterAll
|
||||||
} from '@jest/globals';
|
} from '@jest/globals';
|
||||||
import https from 'https';
|
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 manifestData from '../data/jetbrains.json' with {type: 'json'};
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@ -44,6 +46,18 @@ jest.unstable_mockModule('@actions/core', () => ({
|
|||||||
const core = await import('@actions/core');
|
const core = await import('@actions/core');
|
||||||
const {JetBrainsDistribution} =
|
const {JetBrainsDistribution} =
|
||||||
await import('../../src/distributions/jetbrains/installer.js');
|
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', () => {
|
describe('getAvailableVersions', () => {
|
||||||
let spyHttpClient: any;
|
let spyHttpClient: any;
|
||||||
@ -95,6 +109,42 @@ describe('getAvailableVersions', () => {
|
|||||||
os.platform() === 'win32' ? manifestData.length : manifestData.length + 2;
|
os.platform() === 'win32' ? manifestData.length : manifestData.length + 2;
|
||||||
expect(availableVersions.length).toBe(length);
|
expect(availableVersions.length).toBe(length);
|
||||||
}, 10_000);
|
}, 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', () => {
|
describe('findPackageForDownload', () => {
|
||||||
|
|||||||
251
__tests__/retrying-http-client.test.ts
Normal file
251
__tests__/retrying-http-client.test.ts
Normal file
@ -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<typeof jest.spyOn>;
|
||||||
|
let sleep: jest.Mock<(delayMs: number) => Promise<void>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
dist/setup/index.js
vendored
200
dist/setup/index.js
vendored
@ -129300,6 +129300,112 @@ function isProbablyGradleDaemonProblem(packageManager, error) {
|
|||||||
return message.startsWith('Tar failed with 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
|
;// CONCATENATED MODULE: ./src/distributions/base-installer.ts
|
||||||
|
|
||||||
|
|
||||||
@ -129310,6 +129416,7 @@ function isProbablyGradleDaemonProblem(packageManager, error) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class JavaBase {
|
class JavaBase {
|
||||||
distribution;
|
distribution;
|
||||||
http;
|
http;
|
||||||
@ -129325,10 +129432,7 @@ class JavaBase {
|
|||||||
verifySignaturePublicKey;
|
verifySignaturePublicKey;
|
||||||
constructor(distribution, installerOptions) {
|
constructor(distribution, installerOptions) {
|
||||||
this.distribution = distribution;
|
this.distribution = distribution;
|
||||||
this.http = new lib_HttpClient('actions/setup-java', undefined, {
|
this.http = new RetryingHttpClient('actions/setup-java');
|
||||||
allowRetries: true,
|
|
||||||
maxRetries: 3
|
|
||||||
});
|
|
||||||
({
|
({
|
||||||
version: this.version,
|
version: this.version,
|
||||||
stable: this.stable,
|
stable: this.stable,
|
||||||
@ -129355,26 +129459,10 @@ class JavaBase {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
info('Trying to resolve the latest version from remote');
|
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 {
|
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);
|
const javaRelease = await this.findPackageForDownload(this.version);
|
||||||
info(`Resolved latest version as ${javaRelease.version}`);
|
info(`Resolved latest version as ${javaRelease.version}`);
|
||||||
if (!this.forceDownload &&
|
if (!this.forceDownload && foundJava?.version === javaRelease.version) {
|
||||||
foundJava?.version === javaRelease.version) {
|
|
||||||
info(`Resolved Java ${foundJava.version} from tool-cache`);
|
info(`Resolved Java ${foundJava.version} from tool-cache`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@ -129382,32 +129470,45 @@ class JavaBase {
|
|||||||
foundJava = await this.downloadTool(javaRelease);
|
foundJava = await this.downloadTool(javaRelease);
|
||||||
info(`Java ${foundJava.version} was downloaded`);
|
info(`Java ${foundJava.version} was downloaded`);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
retries--;
|
this.logSetupError(error);
|
||||||
// Check if error is retryable (including aggregate errors)
|
throw error;
|
||||||
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) {
|
if (!foundJava) {
|
||||||
|
throw new Error('Failed to resolve Java version');
|
||||||
|
}
|
||||||
|
// JDK folder may contain postfix "Contents/Home" on macOS
|
||||||
|
const macOSPostfixPath = external_path_default().join(foundJava.path, MACOS_JAVA_CONTENT_POSTFIX);
|
||||||
|
if (process.platform === 'darwin' && external_fs_namespaceObject.existsSync(macOSPostfixPath)) {
|
||||||
|
foundJava.path = macOSPostfixPath;
|
||||||
|
}
|
||||||
|
if (this.setDefault) {
|
||||||
|
info(`Setting Java ${foundJava.version} as the default`);
|
||||||
|
this.setJavaDefault(foundJava.version, foundJava.path);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
info(`Installing Java ${foundJava.version} (not setting as default)`);
|
||||||
|
this.setJavaEnvironment(foundJava.version, foundJava.path);
|
||||||
|
}
|
||||||
|
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.');
|
core_error('HTTP 403: Permission denied or access restricted.');
|
||||||
}
|
}
|
||||||
else if (error.httpStatusCode === 429) {
|
else if (httpStatusCode === 429) {
|
||||||
warning('HTTP 429: Rate limit exceeded. Please retry later.');
|
warning('HTTP 429: Rate limit exceeded. Please retry later.');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
core_error(`HTTP ${error.httpStatusCode}: ${error.message}`);
|
core_error(`HTTP ${httpStatusCode}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (error && error.errors && Array.isArray(error.errors)) {
|
else if (error && error.errors && Array.isArray(error.errors)) {
|
||||||
@ -129453,27 +129554,6 @@ class JavaBase {
|
|||||||
core_debug(`"${key}": ${JSON.stringify(value)}`);
|
core_debug(`"${key}": ${JSON.stringify(value)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!foundJava) {
|
|
||||||
throw new Error('Failed to resolve Java version');
|
|
||||||
}
|
|
||||||
// JDK folder may contain postfix "Contents/Home" on macOS
|
|
||||||
const macOSPostfixPath = external_path_default().join(foundJava.path, MACOS_JAVA_CONTENT_POSTFIX);
|
|
||||||
if (process.platform === 'darwin' && external_fs_namespaceObject.existsSync(macOSPostfixPath)) {
|
|
||||||
foundJava.path = macOSPostfixPath;
|
|
||||||
}
|
|
||||||
if (this.setDefault) {
|
|
||||||
info(`Setting Java ${foundJava.version} as the default`);
|
|
||||||
this.setJavaDefault(foundJava.version, foundJava.path);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
info(`Installing Java ${foundJava.version} (not setting as default)`);
|
|
||||||
this.setJavaEnvironment(foundJava.version, foundJava.path);
|
|
||||||
}
|
|
||||||
return foundJava;
|
|
||||||
}
|
}
|
||||||
get toolcacheFolderName() {
|
get toolcacheFolderName() {
|
||||||
return `Java_${this.distribution}_${this.packageType}`;
|
return `Java_${this.distribution}_${this.packageType}`;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
JavaInstallerResults
|
JavaInstallerResults
|
||||||
} from './base-models.js';
|
} from './base-models.js';
|
||||||
import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js';
|
import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js';
|
||||||
|
import {RetryingHttpClient} from '../retrying-http-client.js';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
export abstract class JavaBase {
|
export abstract class JavaBase {
|
||||||
@ -34,10 +35,7 @@ export abstract class JavaBase {
|
|||||||
protected distribution: string,
|
protected distribution: string,
|
||||||
installerOptions: JavaInstallerOptions
|
installerOptions: JavaInstallerOptions
|
||||||
) {
|
) {
|
||||||
this.http = new httpm.HttpClient('actions/setup-java', undefined, {
|
this.http = new RetryingHttpClient('actions/setup-java');
|
||||||
allowRetries: true,
|
|
||||||
maxRetries: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
({
|
({
|
||||||
version: this.version,
|
version: this.version,
|
||||||
@ -75,69 +73,64 @@ export abstract class JavaBase {
|
|||||||
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
|
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
|
||||||
} else {
|
} else {
|
||||||
core.info('Trying to resolve the latest version from remote');
|
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 {
|
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);
|
const javaRelease = await this.findPackageForDownload(this.version);
|
||||||
core.info(`Resolved latest version as ${javaRelease.version}`);
|
core.info(`Resolved latest version as ${javaRelease.version}`);
|
||||||
if (
|
if (!this.forceDownload && foundJava?.version === javaRelease.version) {
|
||||||
!this.forceDownload &&
|
|
||||||
foundJava?.version === javaRelease.version
|
|
||||||
) {
|
|
||||||
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
|
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
|
||||||
} else {
|
} else {
|
||||||
core.info('Trying to download...');
|
core.info('Trying to download...');
|
||||||
foundJava = await this.downloadTool(javaRelease);
|
foundJava = await this.downloadTool(javaRelease);
|
||||||
core.info(`Java ${foundJava.version} was downloaded`);
|
core.info(`Java ${foundJava.version} was downloaded`);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
retries--;
|
this.logSetupError(error);
|
||||||
// Check if error is retryable (including aggregate errors)
|
throw error;
|
||||||
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) {
|
if (!foundJava) {
|
||||||
core.error('HTTP 403: Permission denied or access restricted.');
|
throw new Error('Failed to resolve Java version');
|
||||||
} else if (error.httpStatusCode === 429) {
|
}
|
||||||
core.warning(
|
// JDK folder may contain postfix "Contents/Home" on macOS
|
||||||
'HTTP 429: Rate limit exceeded. Please retry later.'
|
const macOSPostfixPath = path.join(
|
||||||
|
foundJava.path,
|
||||||
|
MACOS_JAVA_CONTENT_POSTFIX
|
||||||
);
|
);
|
||||||
|
if (process.platform === 'darwin' && fs.existsSync(macOSPostfixPath)) {
|
||||||
|
foundJava.path = macOSPostfixPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.setDefault) {
|
||||||
|
core.info(`Setting Java ${foundJava.version} as the default`);
|
||||||
|
this.setJavaDefault(foundJava.version, foundJava.path);
|
||||||
} else {
|
} else {
|
||||||
core.error(`HTTP ${error.httpStatusCode}: ${error.message}`);
|
core.info(
|
||||||
|
`Installing Java ${foundJava.version} (not setting as default)`
|
||||||
|
);
|
||||||
|
this.setJavaEnvironment(foundJava.version, foundJava.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
} else if (error && error.errors && Array.isArray(error.errors)) {
|
||||||
core.error(
|
core.error(`Java setup failed due to network or configuration error(s)`);
|
||||||
`Java setup failed due to network or configuration error(s)`
|
|
||||||
);
|
|
||||||
if (error instanceof Error && error.stack) {
|
if (error instanceof Error && error.stack) {
|
||||||
core.debug(error.stack);
|
core.debug(error.stack);
|
||||||
}
|
}
|
||||||
@ -180,33 +173,6 @@ export abstract class JavaBase {
|
|||||||
core.debug(`"${key}": ${JSON.stringify(value)}`);
|
core.debug(`"${key}": ${JSON.stringify(value)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!foundJava) {
|
|
||||||
throw new Error('Failed to resolve Java version');
|
|
||||||
}
|
|
||||||
// JDK folder may contain postfix "Contents/Home" on macOS
|
|
||||||
const macOSPostfixPath = path.join(
|
|
||||||
foundJava.path,
|
|
||||||
MACOS_JAVA_CONTENT_POSTFIX
|
|
||||||
);
|
|
||||||
if (process.platform === 'darwin' && fs.existsSync(macOSPostfixPath)) {
|
|
||||||
foundJava.path = macOSPostfixPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.setDefault) {
|
|
||||||
core.info(`Setting Java ${foundJava.version} as the default`);
|
|
||||||
this.setJavaDefault(foundJava.version, foundJava.path);
|
|
||||||
} else {
|
|
||||||
core.info(
|
|
||||||
`Installing Java ${foundJava.version} (not setting as default)`
|
|
||||||
);
|
|
||||||
this.setJavaEnvironment(foundJava.version, foundJava.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return foundJava;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get toolcacheFolderName(): string {
|
protected get toolcacheFolderName(): string {
|
||||||
|
|||||||
166
src/retrying-http-client.ts
Normal file
166
src/retrying-http-client.ts
Normal 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';
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user