feat: add more retry logic and better logging (#1764)

Wraps exportAccountId and validateCredentials calls in retryAndBackoff.
Closes #1681. Adds a label parameter to retryAndBackoff for better
info-level log messages.
This commit is contained in:
Tom Keller
2026-05-11 10:00:14 -07:00
committed by GitHub
parent 07ada0fe07
commit 540d0c13ae
4 changed files with 178 additions and 47 deletions
+6 -4
View File
@@ -189,6 +189,7 @@ export async function retryAndBackoff<T>(
maxRetries = 12, maxRetries = 12,
retries = 0, retries = 0,
base = 50, base = 50,
label?: string,
): Promise<T> { ): Promise<T> {
try { try {
return await fn(); return await fn();
@@ -200,20 +201,21 @@ export async function retryAndBackoff<T>(
// It's retryable, so sleep and retry. // It's retryable, so sleep and retry.
const delay = Math.random() * (2 ** retries * base); const delay = Math.random() * (2 ** retries * base);
const nextRetry = retries + 1; const nextRetry = retries + 1;
const opName = label ? ` ${label}` : '';
core.debug( core.info(
`retryAndBackoff: attempt ${nextRetry} of ${maxRetries} failed: ${errorMessage(err)}. ` + `Retry${opName}: attempt ${nextRetry} of ${maxRetries} failed: ${errorMessage(err)}. ` +
`Retrying after ${Math.floor(delay)}ms.`, `Retrying after ${Math.floor(delay)}ms.`,
); );
await sleep(delay); await sleep(delay);
if (nextRetry >= maxRetries) { if (nextRetry >= maxRetries) {
core.debug('retryAndBackoff: reached max retries; giving up.'); core.info(`Retry${opName}: reached max retries (${maxRetries}); giving up.`);
throw err; throw err;
} }
return await retryAndBackoff(fn, isRetryable, maxRetries, nextRetry, base); return await retryAndBackoff(fn, isRetryable, maxRetries, nextRetry, base, label);
} }
} }
+50 -41
View File
@@ -90,6 +90,9 @@ export async function run() {
maxRetries = 1; maxRetries = 1;
} }
const withRetry = <T>(fn: () => Promise<T>, label: string): Promise<T> =>
retryAndBackoff(fn, !disableRetry, maxRetries, 0, 50, label);
// Logic to decide whether to attempt to use OIDC or not // Logic to decide whether to attempt to use OIDC or not
const useGitHubOIDCProvider = () => { const useGitHubOIDCProvider = () => {
if (forceSkipOidc) return false; if (forceSkipOidc) return false;
@@ -161,13 +164,9 @@ export async function run() {
// Else, export credentials provided as input // Else, export credentials provided as input
if (useGitHubOIDCProvider()) { if (useGitHubOIDCProvider()) {
try { try {
webIdentityToken = await retryAndBackoff( webIdentityToken = await withRetry(async () => {
async () => { return core.getIDToken(audience);
return core.getIDToken(audience); }, 'getIDToken');
},
!disableRetry,
maxRetries,
);
} catch (error) { } catch (error) {
throw new Error(`getIDToken call failed: ${errorMessage(error)}`); throw new Error(`getIDToken call failed: ${errorMessage(error)}`);
} }
@@ -188,16 +187,22 @@ export async function run() {
} }
} else if (!webIdentityTokenFile && !roleChaining) { } else if (!webIdentityTokenFile && !roleChaining) {
// Proceed only if credentials can be picked up // Proceed only if credentials can be picked up
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds); await withRetry(
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId); () => credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds),
'validateCredentials',
);
sourceAccountId = await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
} }
if (AccessKeyId || roleChaining) { if (AccessKeyId || roleChaining) {
// Validate that the SDK can actually pick up credentials. // Validate that the SDK can actually pick up credentials.
// This validates cases where this action is using existing environment credentials, // This validates cases where this action is using existing environment credentials,
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings. // and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds); await withRetry(
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId); () => credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds),
'validateCredentials',
);
sourceAccountId = await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
} }
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) { if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
core.warning( core.warning(
@@ -210,27 +215,23 @@ export async function run() {
if (roleToAssume) { if (roleToAssume) {
let roleCredentials: AssumeRoleCommandOutput; let roleCredentials: AssumeRoleCommandOutput;
do { do {
roleCredentials = await retryAndBackoff( roleCredentials = await withRetry(async () => {
async () => { return assumeRole({
return assumeRole({ credentialsClient,
credentialsClient, sourceAccountId,
sourceAccountId, roleToAssume,
roleToAssume, roleExternalId,
roleExternalId, roleDuration,
roleDuration, roleSessionName,
roleSessionName, roleSkipSessionTagging,
roleSkipSessionTagging, transitiveTagKeys,
transitiveTagKeys, webIdentityTokenFile,
webIdentityTokenFile, webIdentityToken,
webIdentityToken, inlineSessionPolicy,
inlineSessionPolicy, managedSessionPolicies,
managedSessionPolicies, customTags,
customTags, });
}); }, 'AssumeRole');
},
!disableRetry,
maxRetries,
);
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials)); } while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`); core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials); exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
@@ -241,14 +242,18 @@ export async function run() {
// If we are using a profile, don't validate credentials yet (since they most likely won't be in the environment). // If we are using a profile, don't validate credentials yet (since they most likely won't be in the environment).
// Wait until after creds are written to the profile file to try validation. // Wait until after creds are written to the profile file to try validation.
if ((!process.env.GITHUB_ACTIONS || AccessKeyId) && !awsProfile) { if ((!process.env.GITHUB_ACTIONS || AccessKeyId) && !awsProfile) {
await credentialsClient.validateCredentials( await withRetry(
roleCredentials.Credentials?.AccessKeyId, () =>
roleChaining, credentialsClient.validateCredentials(
expectedAccountIds, roleCredentials.Credentials?.AccessKeyId,
roleChaining,
expectedAccountIds,
),
'validateCredentials',
); );
} }
if (outputEnvCredentials) { if (outputEnvCredentials) {
await exportAccountId(credentialsClient, maskAccountId); await withRetry(() => exportAccountId(credentialsClient, maskAccountId), 'exportAccountId');
} }
// Write profile files if profile mode is enabled // Write profile files if profile mode is enabled
@@ -261,10 +266,14 @@ export async function run() {
// We then validate the credentials to make sure they work. // We then validate the credentials to make sure they work.
if (AccessKeyId || !process.env.GITHUB_ACTIONS) { if (AccessKeyId || !process.env.GITHUB_ACTIONS) {
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true); writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true);
await credentialsClient.validateCredentials( await withRetry(
roleCredentials.Credentials.AccessKeyId, () =>
roleChaining, credentialsClient.validateCredentials(
expectedAccountIds, roleCredentials.Credentials?.AccessKeyId,
roleChaining,
expectedAccountIds,
),
'validateCredentials',
); );
} else { } else {
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile); writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile);
+23
View File
@@ -24,6 +24,29 @@ describe('Configure AWS Credentials helpers', {}, () => {
await expect(helpers.retryAndBackoff(fn, false)).rejects.toMatch('i am not retryable'); await expect(helpers.retryAndBackoff(fn, false)).rejects.toMatch('i am not retryable');
expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledTimes(1);
}); });
it('retries and logs with label at info level', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('success');
const result = await helpers.retryAndBackoff(fn, true, 3, 0, 50, 'TestOp');
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: attempt 1 of 3 failed'));
helpers.reset();
});
it('logs max retries reached with label', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValue(new Error('persistent'));
await expect(helpers.retryAndBackoff(fn, true, 2, 0, 50, 'TestOp')).rejects.toThrow('persistent');
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: reached max retries (2)'));
helpers.reset();
});
it('retries without a label (backward compat)', {}, async () => {
helpers.withsleep(() => Promise.resolve());
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('ok');
await helpers.retryAndBackoff(fn, true, 3);
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry: attempt 1 of 3 failed'));
helpers.reset();
});
it('can output creds when told to', {}, () => { it('can output creds when told to', {}, () => {
helpers.exportCredentials( helpers.exportCredentials(
{ AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test', Expiration: new Date(8640000000000000) }, { AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test', Expiration: new Date(8640000000000000) },
+99 -2
View File
@@ -7,8 +7,9 @@ import {
} from '@aws-sdk/client-sts'; } from '@aws-sdk/client-sts';
import { mockClient } from 'aws-sdk-client-mock'; import { mockClient } from 'aws-sdk-client-mock';
import { fs, vol } from 'memfs'; import { fs, vol } from 'memfs';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CredentialsClient } from '../src/CredentialsClient'; import { CredentialsClient } from '../src/CredentialsClient';
import * as helpers from '../src/helpers';
import { run } from '../src/index'; import { run } from '../src/index';
import * as profileManager from '../src/profileManager'; import * as profileManager from '../src/profileManager';
import mocks from './mockinputs.test'; import mocks from './mockinputs.test';
@@ -24,11 +25,17 @@ describe('Configure AWS Credentials', {}, () => {
mockedSTSClient.reset(); mockedSTSClient.reset();
vi.mocked(core.getInput).mockReturnValue(''); vi.mocked(core.getInput).mockReturnValue('');
vi.mocked(core.getMultilineInput).mockReturnValue([]); vi.mocked(core.getMultilineInput).mockReturnValue([]);
// Inject no-op sleep to avoid real delays during retries in tests
helpers.withsleep(() => Promise.resolve());
// Remove any existing environment variables before each test to prevent the // Remove any existing environment variables before each test to prevent the
// SDK from picking them up // SDK from picking them up
process.env = { ...mocks.envs }; process.env = { ...mocks.envs };
}); });
afterEach(() => {
helpers.reset();
});
describe('GitHub OIDC Authentication', {}, () => { describe('GitHub OIDC Authentication', {}, () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -317,7 +324,12 @@ describe('Configure AWS Credentials', {}, () => {
describe('Odd inputs', {}, () => { describe('Odd inputs', {}, () => {
it('fails when github env vars are missing', {}, async () => { it('fails when github env vars are missing', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS)); vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
accessKeyId: 'MYAWSACCESSKEYID',
});
delete process.env.GITHUB_REPOSITORY; delete process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_SHA; delete process.env.GITHUB_SHA;
await run(); await run();
@@ -369,6 +381,10 @@ describe('Configure AWS Credentials', {}, () => {
}); });
it('fails if doing OIDC without the ACTIONS_ID_TOKEN_REQUEST_TOKEN', {}, async () => { it('fails if doing OIDC without the ACTIONS_ID_TOKEN_REQUEST_TOKEN', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS)); vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(
new Error('No credentials available'),
);
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
await run(); await run();
expect(core.setFailed).toHaveBeenCalled(); expect(core.setFailed).toHaveBeenCalled();
@@ -376,6 +392,10 @@ describe('Configure AWS Credentials', {}, () => {
it("gets new creds if told to reuse existing but they're invalid", {}, async () => { it("gets new creds if told to reuse existing but they're invalid", {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.USE_EXISTING_CREDENTIALS_INPUTS)); vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.USE_EXISTING_CREDENTIALS_INPUTS));
mockedSTSClient.on(GetCallerIdentityCommand).rejects(); mockedSTSClient.on(GetCallerIdentityCommand).rejects();
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(
new Error('No credentials available'),
);
await run(); await run();
expect(core.notice).toHaveBeenCalledWith('No valid credentials exist. Running as normal.'); expect(core.notice).toHaveBeenCalledWith('No valid credentials exist. Running as normal.');
}); });
@@ -1140,4 +1160,81 @@ describe('Configure AWS Credentials', {}, () => {
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('whitespace')); expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('whitespace'));
}); });
}); });
describe('Retry Behavior', {}, () => {
it('retries exportAccountId on transient GetCallerIdentity failure', async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
accessKeyId: 'MYAWSACCESSKEYID',
});
mockedSTSClient
.on(GetCallerIdentityCommand)
.rejectsOnce(new Error('throttled'))
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
await run();
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
expect(core.setFailed).not.toHaveBeenCalled();
});
it('retries validateCredentials on transient loadCredentials failure', async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
.mockRejectedValueOnce(new Error('network glitch'))
.mockResolvedValue({ accessKeyId: 'MYAWSACCESSKEYID' });
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
await run();
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry validateCredentials'));
expect(core.setFailed).not.toHaveBeenCalled();
});
it('respects disable-retry for validateCredentials', async () => {
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_USER_INPUTS,
'disable-retry': 'true',
}),
);
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(
new Error('network glitch'),
);
await run();
expect(core.setFailed).toHaveBeenCalled();
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Retry'));
});
it('retries exportAccountId after role assumption (issue #1681)', async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
mockedSTSClient
.on(GetCallerIdentityCommand)
.rejectsOnce(new Error('The security token included in the request is invalid'))
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
await run();
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('The security token included in the request is invalid'),
);
expect(core.setFailed).not.toHaveBeenCalled();
});
it('retries AssumeRole and shows info-level retry messages', async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
mockedSTSClient
.on(AssumeRoleWithWebIdentityCommand)
.rejectsOnce(new Error('Rate exceeded'))
.resolves(mocks.outputs.STS_CREDENTIALS);
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
await run();
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry AssumeRole'));
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Rate exceeded'));
expect(core.setFailed).not.toHaveBeenCalled();
});
});
}); });