From 937a15e3fbf951267216853e9c832ee901306a66 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:04:20 +0200 Subject: [PATCH 01/12] BUILD-10777 Add retryWithBackoff utility with exponential backoff Co-Authored-By: Claude Opus 4.6 --- __tests__/retry.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++ src/retry.ts | 32 ++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 __tests__/retry.test.ts create mode 100644 src/retry.ts diff --git a/__tests__/retry.test.ts b/__tests__/retry.test.ts new file mode 100644 index 0000000..a69a9f2 --- /dev/null +++ b/__tests__/retry.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@actions/core', () => ({ + warning: vi.fn(), + info: vi.fn(), +})); + +import * as core from '@actions/core'; +import { retryWithBackoff } from '../src/retry'; + +describe('retryWithBackoff', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns result on first success', async () => { + const fn = vi.fn().mockResolvedValue('ok'); + const promise = retryWithBackoff(fn, { label: 'test-op' }); + const result = await promise; + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('retries on failure and succeeds', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce('recovered'); + + const promise = retryWithBackoff(fn, { label: 'test-op' }); + // Advance past first retry delay (1000ms base) + await vi.advanceTimersByTimeAsync(1100); + const result = await promise; + + expect(result).toBe('recovered'); + expect(fn).toHaveBeenCalledTimes(2); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('test-op failed (attempt 1/3)') + ); + }); + + it('throws after all retries exhausted', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('persistent')) + .mockRejectedValueOnce(new Error('persistent')); + + const promise = retryWithBackoff(fn, { label: 'test-op', maxAttempts: 2 }); + // Attach rejection handler immediately to prevent unhandled rejection + const resultPromise = promise.catch((e: Error) => e); + // Advance past retry delay + await vi.advanceTimersByTimeAsync(1100); + const result = await resultPromise; + + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toBe('persistent'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('respects custom maxAttempts and baseDelayMs', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail-1')) + .mockRejectedValueOnce(new Error('fail-2')) + .mockRejectedValueOnce(new Error('fail-3')) + .mockResolvedValueOnce('ok'); + + const promise = retryWithBackoff(fn, { + label: 'custom', + maxAttempts: 4, + baseDelayMs: 500, + }); + // Advance through 3 retry delays: 500 + 1000 + 2000 = 3500ms + await vi.advanceTimersByTimeAsync(4000); + const result = await promise; + + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..827090f --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,32 @@ +import * as core from '@actions/core'; + +export interface RetryOptions { + label: string; + maxAttempts?: number; + baseDelayMs?: number; +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions +): Promise { + const { label, maxAttempts = 3, baseDelayMs = 1000 } = options; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + const delayMs = baseDelayMs * Math.pow(2, attempt - 1); + const message = error instanceof Error ? error.message : String(error); + core.warning( + `${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...` + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + // Unreachable, but satisfies TypeScript + throw new Error(`${label} failed after ${maxAttempts} attempts`); +} From 4160d3d4835e57d70f6b791872dd51dc85d75283 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:07:26 +0200 Subject: [PATCH 02/12] BUILD-10777 chore: remove unused core.info mock from retry tests Co-Authored-By: Claude Opus 4.6 --- __tests__/retry.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/__tests__/retry.test.ts b/__tests__/retry.test.ts index a69a9f2..4552a86 100644 --- a/__tests__/retry.test.ts +++ b/__tests__/retry.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('@actions/core', () => ({ warning: vi.fn(), - info: vi.fn(), })); import * as core from '@actions/core'; From 084e84768f36b6c264b79f939626bb3b02a67340 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:08:54 +0200 Subject: [PATCH 03/12] BUILD-10777 Add retry logic to getCognitoCredentials Wrap the three external calls (GitHub OIDC token, Cognito GetId, Cognito GetCredentials) with retryWithBackoff for transient failure resilience. Add retryOptions to AuthConfig so tests can override delays. Co-Authored-By: Claude Opus 4.6 --- src/auth.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index c128edc..44be154 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,6 +4,7 @@ import { GetIdCommand, GetCredentialsForIdentityCommand, } from '@aws-sdk/client-cognito-identity'; +import { retryWithBackoff, RetryOptions } from './retry'; const IDENTITY_PROVIDER = 'token.actions.githubusercontent.com'; const AUDIENCE = 'cognito-identity.amazonaws.com'; @@ -12,6 +13,7 @@ export interface AuthConfig { poolId: string; accountId: string; region: string; + retryOptions?: Partial; } export interface AwsCredentials { @@ -22,20 +24,26 @@ export interface AwsCredentials { } export async function getCognitoCredentials(config: AuthConfig): Promise { + const retryOpts = { maxAttempts: 3, baseDelayMs: 1000, ...config.retryOptions }; + core.info('Requesting GitHub OIDC token...'); - const oidcToken = await core.getIDToken(AUDIENCE); + const oidcToken = await retryWithBackoff( + () => core.getIDToken(AUDIENCE), + { label: 'GitHub OIDC token', ...retryOpts } + ); core.setSecret(oidcToken); const client = new CognitoIdentityClient({ region: config.region }); const logins = { [IDENTITY_PROVIDER]: oidcToken }; core.info('Exchanging OIDC token for Cognito identity...'); - const { IdentityId } = await client.send( - new GetIdCommand({ + const { IdentityId } = await retryWithBackoff( + () => client.send(new GetIdCommand({ IdentityPoolId: config.poolId, AccountId: config.accountId, Logins: logins, - }) + })), + { label: 'Cognito GetId', ...retryOpts } ); if (!IdentityId) { @@ -43,11 +51,12 @@ export async function getCognitoCredentials(config: AuthConfig): Promise client.send(new GetCredentialsForIdentityCommand({ IdentityId, Logins: logins, - }) + })), + { label: 'Cognito GetCredentials', ...retryOpts } ); if (!Credentials?.AccessKeyId || !Credentials?.SecretKey || !Credentials?.SessionToken) { From 1b694b5888778dd11c52daa6a32b422b76d7c044 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:12:46 +0200 Subject: [PATCH 04/12] BUILD-10777 chore: tighten retryOptions type to exclude label Co-Authored-By: Claude Opus 4.6 --- src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 44be154..b3ba2f0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -13,7 +13,7 @@ export interface AuthConfig { poolId: string; accountId: string; region: string; - retryOptions?: Partial; + retryOptions?: Partial>; } export interface AwsCredentials { From 0b531ba7b49db01af7369a24ee5a5e97ac655ed7 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:14:21 +0200 Subject: [PATCH 05/12] BUILD-10777 Add retry-specific tests to auth.test.ts and speed up credential-setup tests Co-Authored-By: Claude Opus 4.6 --- __tests__/auth.test.ts | 95 ++++++++++++++++++++++++++++++ __tests__/credential-setup.test.ts | 9 +++ 2 files changed, 104 insertions(+) diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts index b59d1fc..23641df 100644 --- a/__tests__/auth.test.ts +++ b/__tests__/auth.test.ts @@ -5,6 +5,7 @@ vi.mock('@actions/core', () => ({ info: vi.fn(), setSecret: vi.fn(), error: vi.fn(), + warning: vi.fn(), })); vi.mock('@aws-sdk/client-cognito-identity', () => { @@ -21,6 +22,8 @@ import * as core from '@actions/core'; import { __sendMock as sendMock } from '@aws-sdk/client-cognito-identity'; import { getCognitoCredentials } from '../src/auth'; +const fastRetry = { retryOptions: { baseDelayMs: 1 } }; + describe('getCognitoCredentials', () => { beforeEach(() => { vi.clearAllMocks(); @@ -76,4 +79,96 @@ describe('getCognitoCredentials', () => { getCognitoCredentials({ poolId: 'pool', accountId: '123', region: 'eu-central-1' }) ).rejects.toThrow('Failed to obtain AWS credentials'); }); + + it('retries on transient OIDC token failure', async () => { + vi.mocked(core.getIDToken) + .mockRejectedValueOnce(new Error('OIDC timeout')) + .mockResolvedValueOnce('oidc-token-retry'); + + sendMock + .mockResolvedValueOnce({ IdentityId: 'eu-central-1:identity-abc' }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIARETRY', + SecretKey: 'secret-retry', + SessionToken: 'token-retry', + Expiration: new Date('2026-01-01T00:00:00Z'), + }, + }); + + const result = await getCognitoCredentials({ + poolId: 'eu-central-1:pool-123', + accountId: '123456789', + region: 'eu-central-1', + ...fastRetry, + }); + + expect(result.accessKeyId).toBe('AKIARETRY'); + expect(core.getIDToken).toHaveBeenCalledTimes(2); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('GitHub OIDC token failed (attempt 1/3)') + ); + }); + + it('retries on transient Cognito GetId failure', async () => { + vi.mocked(core.getIDToken).mockResolvedValue('token'); + + sendMock + .mockRejectedValueOnce(new Error('Cognito throttle')) + .mockResolvedValueOnce({ IdentityId: 'id-recovered' }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA2', + SecretKey: 'secret2', + SessionToken: 'token2', + Expiration: new Date('2026-01-01T00:00:00Z'), + }, + }); + + const result = await getCognitoCredentials({ + poolId: 'pool', + accountId: '123', + region: 'eu-central-1', + ...fastRetry, + }); + + expect(result.accessKeyId).toBe('AKIA2'); + expect(sendMock).toHaveBeenCalledTimes(3); // 1 fail + 1 GetId success + 1 GetCreds success + }); + + it('retries on transient Cognito GetCredentials failure', async () => { + vi.mocked(core.getIDToken).mockResolvedValue('token'); + + sendMock + .mockResolvedValueOnce({ IdentityId: 'id-123' }) + .mockRejectedValueOnce(new Error('Cognito 500')) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA3', + SecretKey: 'secret3', + SessionToken: 'token3', + Expiration: new Date('2026-01-01T00:00:00Z'), + }, + }); + + const result = await getCognitoCredentials({ + poolId: 'pool', + accountId: '123', + region: 'eu-central-1', + ...fastRetry, + }); + + expect(result.accessKeyId).toBe('AKIA3'); + expect(sendMock).toHaveBeenCalledTimes(3); // 1 GetId + 1 GetCreds fail + 1 GetCreds success + }); + + it('throws after all OIDC retries exhausted', async () => { + vi.mocked(core.getIDToken).mockRejectedValue(new Error('OIDC down')); + + await expect( + getCognitoCredentials({ poolId: 'pool', accountId: '123', region: 'eu-central-1', ...fastRetry }) + ).rejects.toThrow('OIDC down'); + + expect(core.getIDToken).toHaveBeenCalledTimes(3); + }); }); diff --git a/__tests__/credential-setup.test.ts b/__tests__/credential-setup.test.ts index 95b6ee2..500f08c 100644 --- a/__tests__/credential-setup.test.ts +++ b/__tests__/credential-setup.test.ts @@ -3,6 +3,15 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; +vi.mock('../src/retry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + retryWithBackoff: (fn: () => Promise, opts: Record) => + actual.retryWithBackoff(fn, { ...opts, baseDelayMs: 1 }), + }; +}); + vi.mock('@actions/core', () => ({ getInput: vi.fn(), setOutput: vi.fn(), From eb6d2d4ca9287d0594bef1f0d58d8bd3d87196b7 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:16:45 +0200 Subject: [PATCH 06/12] BUILD-10777 chore: rebuild dist bundles with retry logic Co-Authored-By: Claude Opus 4.6 --- credential-setup/dist/index.js | 76 +++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index a48a6fc..e9901aa 100644 --- a/credential-setup/dist/index.js +++ b/credential-setup/dist/index.js @@ -43995,28 +43995,30 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getCognitoCredentials = getCognitoCredentials; const core = __importStar(__nccwpck_require__(7484)); const client_cognito_identity_1 = __nccwpck_require__(6473); +const retry_1 = __nccwpck_require__(9809); const IDENTITY_PROVIDER = 'token.actions.githubusercontent.com'; const AUDIENCE = 'cognito-identity.amazonaws.com'; async function getCognitoCredentials(config) { + const retryOpts = { maxAttempts: 3, baseDelayMs: 1000, ...config.retryOptions }; core.info('Requesting GitHub OIDC token...'); - const oidcToken = await core.getIDToken(AUDIENCE); + const oidcToken = await (0, retry_1.retryWithBackoff)(() => core.getIDToken(AUDIENCE), { label: 'GitHub OIDC token', ...retryOpts }); core.setSecret(oidcToken); const client = new client_cognito_identity_1.CognitoIdentityClient({ region: config.region }); const logins = { [IDENTITY_PROVIDER]: oidcToken }; core.info('Exchanging OIDC token for Cognito identity...'); - const { IdentityId } = await client.send(new client_cognito_identity_1.GetIdCommand({ + const { IdentityId } = await (0, retry_1.retryWithBackoff)(() => client.send(new client_cognito_identity_1.GetIdCommand({ IdentityPoolId: config.poolId, AccountId: config.accountId, Logins: logins, - })); + })), { label: 'Cognito GetId', ...retryOpts }); if (!IdentityId) { throw new Error('Failed to obtain Identity ID from Cognito Identity Pool'); } core.info('Obtaining AWS credentials from Cognito...'); - const { Credentials } = await client.send(new client_cognito_identity_1.GetCredentialsForIdentityCommand({ + const { Credentials } = await (0, retry_1.retryWithBackoff)(() => client.send(new client_cognito_identity_1.GetCredentialsForIdentityCommand({ IdentityId, Logins: logins, - })); + })), { label: 'Cognito GetCredentials', ...retryOpts }); if (!Credentials?.AccessKeyId || !Credentials?.SecretKey || !Credentials?.SessionToken) { throw new Error('Failed to obtain AWS credentials from Cognito'); } @@ -44134,6 +44136,70 @@ if (!process.env.VITEST) { } +/***/ }), + +/***/ 9809: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.retryWithBackoff = retryWithBackoff; +const core = __importStar(__nccwpck_require__(7484)); +async function retryWithBackoff(fn, options) { + const { label, maxAttempts = 3, baseDelayMs = 1000 } = options; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } + catch (error) { + if (attempt === maxAttempts) { + throw error; + } + const delayMs = baseDelayMs * Math.pow(2, attempt - 1); + const message = error instanceof Error ? error.message : String(error); + core.warning(`${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + // Unreachable, but satisfies TypeScript + throw new Error(`${label} failed after ${maxAttempts} attempts`); +} + + /***/ }), /***/ 2613: From 526ff1271fffd2ba434be6931671bbc53fcc03f9 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:21:04 +0200 Subject: [PATCH 07/12] BUILD-10777 feat: add jitter to exponential backoff to prevent thundering herd Jitter range: 50-100% of base delay. Prevents concurrent runners from retrying in lockstep after a transient outage. Co-Authored-By: Claude Opus 4.6 --- src/retry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/retry.ts b/src/retry.ts index 827090f..88ab86f 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -19,7 +19,8 @@ export async function retryWithBackoff( if (attempt === maxAttempts) { throw error; } - const delayMs = baseDelayMs * Math.pow(2, attempt - 1); + const jitter = 0.5 + Math.random() * 0.5; + const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); const message = error instanceof Error ? error.message : String(error); core.warning( `${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...` From 45862434f36b50e192110fb9ce5cb2577544e7ab Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 10:21:46 +0200 Subject: [PATCH 08/12] BUILD-10777 chore: rebuild dist bundles with jitter Co-Authored-By: Claude Opus 4.6 --- credential-setup/dist/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index e9901aa..5fac589 100644 --- a/credential-setup/dist/index.js +++ b/credential-setup/dist/index.js @@ -44189,7 +44189,8 @@ async function retryWithBackoff(fn, options) { if (attempt === maxAttempts) { throw error; } - const delayMs = baseDelayMs * Math.pow(2, attempt - 1); + const jitter = 0.5 + Math.random() * 0.5; + const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); const message = error instanceof Error ? error.message : String(error); core.warning(`${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`); await new Promise((resolve) => setTimeout(resolve, delayMs)); From 428911d425835b919b3d06fa8600f9229740a559 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 13:43:38 +0200 Subject: [PATCH 09/12] BUILD-10777 fix: extract magic numbers into named constants Co-Authored-By: Claude Opus 4.6 --- __tests__/auth.test.ts | 11 +++++++---- __tests__/retry.test.ts | 26 ++++++++++++++------------ src/auth.ts | 2 +- src/retry.ts | 8 ++++++-- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts index 23641df..451166f 100644 --- a/__tests__/auth.test.ts +++ b/__tests__/auth.test.ts @@ -21,6 +21,7 @@ vi.mock('@aws-sdk/client-cognito-identity', () => { import * as core from '@actions/core'; import { __sendMock as sendMock } from '@aws-sdk/client-cognito-identity'; import { getCognitoCredentials } from '../src/auth'; +import { DEFAULT_MAX_ATTEMPTS } from '../src/retry'; const fastRetry = { retryOptions: { baseDelayMs: 1 } }; @@ -106,7 +107,7 @@ describe('getCognitoCredentials', () => { expect(result.accessKeyId).toBe('AKIARETRY'); expect(core.getIDToken).toHaveBeenCalledTimes(2); expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining('GitHub OIDC token failed (attempt 1/3)') + expect.stringContaining(`GitHub OIDC token failed (attempt 1/${DEFAULT_MAX_ATTEMPTS})`) ); }); @@ -133,7 +134,8 @@ describe('getCognitoCredentials', () => { }); expect(result.accessKeyId).toBe('AKIA2'); - expect(sendMock).toHaveBeenCalledTimes(3); // 1 fail + 1 GetId success + 1 GetCreds success + // 1 fail + 1 GetId success + 1 GetCreds success + expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS); }); it('retries on transient Cognito GetCredentials failure', async () => { @@ -159,7 +161,8 @@ describe('getCognitoCredentials', () => { }); expect(result.accessKeyId).toBe('AKIA3'); - expect(sendMock).toHaveBeenCalledTimes(3); // 1 GetId + 1 GetCreds fail + 1 GetCreds success + // 1 GetId + 1 GetCreds fail + 1 GetCreds success + expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS); }); it('throws after all OIDC retries exhausted', async () => { @@ -169,6 +172,6 @@ describe('getCognitoCredentials', () => { getCognitoCredentials({ poolId: 'pool', accountId: '123', region: 'eu-central-1', ...fastRetry }) ).rejects.toThrow('OIDC down'); - expect(core.getIDToken).toHaveBeenCalledTimes(3); + expect(core.getIDToken).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS); }); }); diff --git a/__tests__/retry.test.ts b/__tests__/retry.test.ts index 4552a86..bbe6b9d 100644 --- a/__tests__/retry.test.ts +++ b/__tests__/retry.test.ts @@ -5,7 +5,9 @@ vi.mock('@actions/core', () => ({ })); import * as core from '@actions/core'; -import { retryWithBackoff } from '../src/retry'; +import { retryWithBackoff, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_ATTEMPTS } from '../src/retry'; + +const TIMER_MARGIN_MS = 100; describe('retryWithBackoff', () => { beforeEach(() => { @@ -31,14 +33,13 @@ describe('retryWithBackoff', () => { .mockResolvedValueOnce('recovered'); const promise = retryWithBackoff(fn, { label: 'test-op' }); - // Advance past first retry delay (1000ms base) - await vi.advanceTimersByTimeAsync(1100); + await vi.advanceTimersByTimeAsync(DEFAULT_BASE_DELAY_MS + TIMER_MARGIN_MS); const result = await promise; expect(result).toBe('recovered'); expect(fn).toHaveBeenCalledTimes(2); expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining('test-op failed (attempt 1/3)') + expect.stringContaining(`test-op failed (attempt 1/${DEFAULT_MAX_ATTEMPTS})`) ); }); @@ -48,10 +49,8 @@ describe('retryWithBackoff', () => { .mockRejectedValueOnce(new Error('persistent')); const promise = retryWithBackoff(fn, { label: 'test-op', maxAttempts: 2 }); - // Attach rejection handler immediately to prevent unhandled rejection const resultPromise = promise.catch((e: Error) => e); - // Advance past retry delay - await vi.advanceTimersByTimeAsync(1100); + await vi.advanceTimersByTimeAsync(DEFAULT_BASE_DELAY_MS + TIMER_MARGIN_MS); const result = await resultPromise; expect(result).toBeInstanceOf(Error); @@ -66,16 +65,19 @@ describe('retryWithBackoff', () => { .mockRejectedValueOnce(new Error('fail-3')) .mockResolvedValueOnce('ok'); + const customBaseDelay = 500; + const customMaxAttempts = 4; const promise = retryWithBackoff(fn, { label: 'custom', - maxAttempts: 4, - baseDelayMs: 500, + maxAttempts: customMaxAttempts, + baseDelayMs: customBaseDelay, }); - // Advance through 3 retry delays: 500 + 1000 + 2000 = 3500ms - await vi.advanceTimersByTimeAsync(4000); + // Max total delay: 500 + 1000 + 2000 = 3500ms + const maxTotalDelay = customBaseDelay + customBaseDelay * 2 + customBaseDelay * 4; + await vi.advanceTimersByTimeAsync(maxTotalDelay + TIMER_MARGIN_MS); const result = await promise; expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(4); + expect(fn).toHaveBeenCalledTimes(customMaxAttempts); }); }); diff --git a/src/auth.ts b/src/auth.ts index b3ba2f0..148d79d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -24,7 +24,7 @@ export interface AwsCredentials { } export async function getCognitoCredentials(config: AuthConfig): Promise { - const retryOpts = { maxAttempts: 3, baseDelayMs: 1000, ...config.retryOptions }; + const retryOpts = { ...config.retryOptions }; core.info('Requesting GitHub OIDC token...'); const oidcToken = await retryWithBackoff( diff --git a/src/retry.ts b/src/retry.ts index 88ab86f..f99e01e 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,5 +1,9 @@ import * as core from '@actions/core'; +export const DEFAULT_MAX_ATTEMPTS = 3; +export const DEFAULT_BASE_DELAY_MS = 1000; +const JITTER_MIN = 0.5; + export interface RetryOptions { label: string; maxAttempts?: number; @@ -10,7 +14,7 @@ export async function retryWithBackoff( fn: () => Promise, options: RetryOptions ): Promise { - const { label, maxAttempts = 3, baseDelayMs = 1000 } = options; + const { label, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS } = options; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { @@ -19,7 +23,7 @@ export async function retryWithBackoff( if (attempt === maxAttempts) { throw error; } - const jitter = 0.5 + Math.random() * 0.5; + const jitter = JITTER_MIN + Math.random() * (1 - JITTER_MIN); const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); const message = error instanceof Error ? error.message : String(error); core.warning( From 27a5bda4634564c6d513b18e8be0a2894011badc Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 13:45:04 +0200 Subject: [PATCH 10/12] BUILD-10777 chore: rebuild dist bundles Co-Authored-By: Claude Opus 4.6 --- credential-setup/dist/index.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index 5fac589..7ea258b 100644 --- a/credential-setup/dist/index.js +++ b/credential-setup/dist/index.js @@ -43999,7 +43999,7 @@ const retry_1 = __nccwpck_require__(9809); const IDENTITY_PROVIDER = 'token.actions.githubusercontent.com'; const AUDIENCE = 'cognito-identity.amazonaws.com'; async function getCognitoCredentials(config) { - const retryOpts = { maxAttempts: 3, baseDelayMs: 1000, ...config.retryOptions }; + const retryOpts = { ...config.retryOptions }; core.info('Requesting GitHub OIDC token...'); const oidcToken = await (0, retry_1.retryWithBackoff)(() => core.getIDToken(AUDIENCE), { label: 'GitHub OIDC token', ...retryOpts }); core.setSecret(oidcToken); @@ -44177,10 +44177,14 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.DEFAULT_BASE_DELAY_MS = exports.DEFAULT_MAX_ATTEMPTS = void 0; exports.retryWithBackoff = retryWithBackoff; const core = __importStar(__nccwpck_require__(7484)); +exports.DEFAULT_MAX_ATTEMPTS = 3; +exports.DEFAULT_BASE_DELAY_MS = 1000; +const JITTER_MIN = 0.5; async function retryWithBackoff(fn, options) { - const { label, maxAttempts = 3, baseDelayMs = 1000 } = options; + const { label, maxAttempts = exports.DEFAULT_MAX_ATTEMPTS, baseDelayMs = exports.DEFAULT_BASE_DELAY_MS } = options; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); @@ -44189,7 +44193,7 @@ async function retryWithBackoff(fn, options) { if (attempt === maxAttempts) { throw error; } - const jitter = 0.5 + Math.random() * 0.5; + const jitter = JITTER_MIN + Math.random() * (1 - JITTER_MIN); const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); const message = error instanceof Error ? error.message : String(error); core.warning(`${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`); From 63fc691bbf4acb5877da55e22406be08dff3b696 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 13:49:18 +0200 Subject: [PATCH 11/12] BUILD-10777 feat: increase default retry base delay to 5s for Cognito rate limiting Cognito Rate exceeded errors need longer backoff windows. New delays: ~5s first retry, ~10s second retry (with jitter). Co-Authored-By: Claude Opus 4.6 --- credential-setup/dist/index.js | 2 +- src/retry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index 7ea258b..80dc0c5 100644 --- a/credential-setup/dist/index.js +++ b/credential-setup/dist/index.js @@ -44181,7 +44181,7 @@ exports.DEFAULT_BASE_DELAY_MS = exports.DEFAULT_MAX_ATTEMPTS = void 0; exports.retryWithBackoff = retryWithBackoff; const core = __importStar(__nccwpck_require__(7484)); exports.DEFAULT_MAX_ATTEMPTS = 3; -exports.DEFAULT_BASE_DELAY_MS = 1000; +exports.DEFAULT_BASE_DELAY_MS = 5000; const JITTER_MIN = 0.5; async function retryWithBackoff(fn, options) { const { label, maxAttempts = exports.DEFAULT_MAX_ATTEMPTS, baseDelayMs = exports.DEFAULT_BASE_DELAY_MS } = options; diff --git a/src/retry.ts b/src/retry.ts index f99e01e..846305b 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,7 +1,7 @@ import * as core from '@actions/core'; export const DEFAULT_MAX_ATTEMPTS = 3; -export const DEFAULT_BASE_DELAY_MS = 1000; +export const DEFAULT_BASE_DELAY_MS = 5000; const JITTER_MIN = 0.5; export interface RetryOptions { From e753b3e0f66272edfe83c49b2354b4c45e970553 Mon Sep 17 00:00:00 2001 From: Mikolaj Matuszny Date: Mon, 30 Mar 2026 13:52:24 +0200 Subject: [PATCH 12/12] BUILD-10777 fix: replace Math.random with crypto.randomInt for SQ compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Math.random() flagged as security hotspot (non-CSPRNG). Use node:crypto randomInt instead — same jitter behavior, no SQ finding. Co-Authored-By: Claude Opus 4.6 --- credential-setup/dist/index.js | 8 +++++--- src/retry.ts | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index 80dc0c5..64a3517 100644 --- a/credential-setup/dist/index.js +++ b/credential-setup/dist/index.js @@ -44180,9 +44180,11 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DEFAULT_BASE_DELAY_MS = exports.DEFAULT_MAX_ATTEMPTS = void 0; exports.retryWithBackoff = retryWithBackoff; const core = __importStar(__nccwpck_require__(7484)); +const node_crypto_1 = __nccwpck_require__(7598); exports.DEFAULT_MAX_ATTEMPTS = 3; exports.DEFAULT_BASE_DELAY_MS = 5000; -const JITTER_MIN = 0.5; +const JITTER_MIN_PCT = 50; +const JITTER_RANGE_PCT = 50; async function retryWithBackoff(fn, options) { const { label, maxAttempts = exports.DEFAULT_MAX_ATTEMPTS, baseDelayMs = exports.DEFAULT_BASE_DELAY_MS } = options; for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -44193,8 +44195,8 @@ async function retryWithBackoff(fn, options) { if (attempt === maxAttempts) { throw error; } - const jitter = JITTER_MIN + Math.random() * (1 - JITTER_MIN); - const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); + const jitterPct = JITTER_MIN_PCT + (0, node_crypto_1.randomInt)(JITTER_RANGE_PCT + 1); + const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitterPct / 100); const message = error instanceof Error ? error.message : String(error); core.warning(`${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`); await new Promise((resolve) => setTimeout(resolve, delayMs)); diff --git a/src/retry.ts b/src/retry.ts index 846305b..97ae7a0 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,8 +1,10 @@ import * as core from '@actions/core'; +import { randomInt } from 'node:crypto'; export const DEFAULT_MAX_ATTEMPTS = 3; export const DEFAULT_BASE_DELAY_MS = 5000; -const JITTER_MIN = 0.5; +const JITTER_MIN_PCT = 50; +const JITTER_RANGE_PCT = 50; export interface RetryOptions { label: string; @@ -23,8 +25,8 @@ export async function retryWithBackoff( if (attempt === maxAttempts) { throw error; } - const jitter = JITTER_MIN + Math.random() * (1 - JITTER_MIN); - const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter); + const jitterPct = JITTER_MIN_PCT + randomInt(JITTER_RANGE_PCT + 1); + const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitterPct / 100); const message = error instanceof Error ? error.message : String(error); core.warning( `${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`