diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts index b59d1fc..451166f 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', () => { @@ -20,6 +21,9 @@ 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 } }; describe('getCognitoCredentials', () => { beforeEach(() => { @@ -76,4 +80,98 @@ 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/${DEFAULT_MAX_ATTEMPTS})`) + ); + }); + + 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'); + // 1 fail + 1 GetId success + 1 GetCreds success + expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS); + }); + + 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'); + // 1 GetId + 1 GetCreds fail + 1 GetCreds success + expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS); + }); + + 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(DEFAULT_MAX_ATTEMPTS); + }); }); 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(), diff --git a/__tests__/retry.test.ts b/__tests__/retry.test.ts new file mode 100644 index 0000000..bbe6b9d --- /dev/null +++ b/__tests__/retry.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@actions/core', () => ({ + warning: vi.fn(), +})); + +import * as core from '@actions/core'; +import { retryWithBackoff, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_ATTEMPTS } from '../src/retry'; + +const TIMER_MARGIN_MS = 100; + +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' }); + 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/${DEFAULT_MAX_ATTEMPTS})`) + ); + }); + + 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 }); + const resultPromise = promise.catch((e: Error) => e); + await vi.advanceTimersByTimeAsync(DEFAULT_BASE_DELAY_MS + TIMER_MARGIN_MS); + 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 customBaseDelay = 500; + const customMaxAttempts = 4; + const promise = retryWithBackoff(fn, { + label: 'custom', + maxAttempts: customMaxAttempts, + baseDelayMs: customBaseDelay, + }); + // 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(customMaxAttempts); + }); +}); diff --git a/credential-setup/dist/index.js b/credential-setup/dist/index.js index a48a6fc..64a3517 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 = { ...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,77 @@ 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.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_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++) { + try { + return await fn(); + } + catch (error) { + if (attempt === maxAttempts) { + throw error; + } + 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)); + } + } + // Unreachable, but satisfies TypeScript + throw new Error(`${label} failed after ${maxAttempts} attempts`); +} + + /***/ }), /***/ 2613: diff --git a/src/auth.ts b/src/auth.ts index c128edc..148d79d 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 = { ...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) { diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..97ae7a0 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,39 @@ +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_PCT = 50; +const JITTER_RANGE_PCT = 50; + +export interface RetryOptions { + label: string; + maxAttempts?: number; + baseDelayMs?: number; +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions +): Promise { + const { label, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS } = options; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + 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...` + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + // Unreachable, but satisfies TypeScript + throw new Error(`${label} failed after ${maxAttempts} attempts`); +}