9
0
Fork 0
mirror of https://github.com/docker/login-action.git synced 2026-04-10 19:34:16 +00:00

Add retry logic for transient login failures

Adds configurable retry mechanism with basic exponential backoff to handle intermittent failures when authenticating to container registries, particularly GCP (GAR/GCR) where I'm seeing errors intermittently.

- Add retry-attempts input (default: 0 for backward compatibility, making it opt in)
- Add retry-delay input (default: 5000ms)
- Implement exponential backoff retry logic in docker login
  - Chose to just write a simple retry function vs. going with a library
- Retry all errors except 5xxs
  - I'm seeing intermittent 401 failures
- Add tests for retry behavior
- Update README with new input parameters

Signed-off-by: Naush Korai <naush.korai@mixpanel.com>
This commit is contained in:
Naush Korai 2026-01-30 13:32:24 -05:00
parent 3227f5311c
commit 47690b2d19
7 changed files with 201 additions and 32 deletions

View file

@ -5,6 +5,11 @@ import * as yaml from 'js-yaml';
import {Buildx} from '@docker/actions-toolkit/lib/buildx/buildx';
import {Util} from '@docker/actions-toolkit/lib/util';
export interface RetryArgs {
attempts: number;
delayMs: number;
}
export interface Inputs {
registry: string;
username: string;
@ -13,6 +18,7 @@ export interface Inputs {
ecr: string;
logout: boolean;
registryAuth: string;
retryArgs: RetryArgs;
}
export interface Auth {
@ -22,6 +28,7 @@ export interface Auth {
scope: string;
ecr: string;
configDir: string;
retryArgs: RetryArgs;
}
export function getInputs(): Inputs {
@ -32,7 +39,11 @@ export function getInputs(): Inputs {
scope: core.getInput('scope'),
ecr: core.getInput('ecr'),
logout: core.getBooleanInput('logout'),
registryAuth: core.getInput('registry-auth')
registryAuth: core.getInput('registry-auth'),
retryArgs: {
attempts: parseInt(core.getInput('retry-attempts') || '0', 10),
delayMs: parseInt(core.getInput('retry-delay') || '5000', 10)
}
};
}
@ -48,7 +59,8 @@ export function getAuthList(inputs: Inputs): Array<Auth> {
password: inputs.password,
scope: inputs.scope,
ecr: inputs.ecr || 'auto',
configDir: scopeToConfigDir(inputs.registry, inputs.scope)
configDir: scopeToConfigDir(inputs.registry, inputs.scope),
retryArgs: inputs.retryArgs
});
} else {
auths = (yaml.load(inputs.registryAuth) as Array<Auth>).map(auth => {
@ -59,7 +71,8 @@ export function getAuthList(inputs: Inputs): Array<Auth> {
password: auth.password,
scope: auth.scope,
ecr: auth.ecr || 'auto',
configDir: scopeToConfigDir(auth.registry || 'docker.io', auth.scope)
configDir: scopeToConfigDir(auth.registry || 'docker.io', auth.scope),
retryArgs: auth.retryArgs || inputs.retryArgs
};
});
}

View file

@ -5,11 +5,47 @@ import * as context from './context';
import {Docker} from '@docker/actions-toolkit/lib/docker/docker';
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isRetryableError(error: Error): boolean {
const errorMsg = error.message.toLowerCase();
const statusCode5xxPattern = /\b5\d{2}\b/;
return !statusCode5xxPattern.test(errorMsg);
}
async function withRetry<T>(fn: () => Promise<T>, retryArgs: context.RetryArgs, context: string): Promise<T> {
const maxAttempts = Math.max(1, retryArgs.attempts + 1);
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts || !isRetryableError(lastError)) {
if (attempt > 1) {
core.info(`${context}: Failed after ${attempt} attempts`);
}
throw lastError;
}
const delay = retryArgs.delayMs * Math.pow(2, attempt - 1);
core.warning(`${context}: Attempt ${attempt}/${maxAttempts} failed: ${lastError.message}. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
throw lastError!;
}
export async function login(auth: context.Auth): Promise<void> {
if (/true/i.test(auth.ecr) || (auth.ecr == 'auto' && aws.isECR(auth.registry))) {
await loginECR(auth.registry, auth.username, auth.password, auth.scope);
await loginECR(auth.registry, auth.username, auth.password, auth.scope, auth.retryArgs);
} else {
await loginStandard(auth.registry, auth.username, auth.password, auth.scope);
await loginStandard(auth.registry, auth.username, auth.password, auth.scope, auth.retryArgs);
}
}
@ -33,7 +69,7 @@ export async function logout(registry: string, configDir: string): Promise<void>
});
}
export async function loginStandard(registry: string, username: string, password: string, scope?: string): Promise<void> {
export async function loginStandard(registry: string, username: string, password: string, scope?: string, retryArgs?: context.RetryArgs): Promise<void> {
if (!username && !password) {
throw new Error('Username and password required');
}
@ -43,18 +79,18 @@ export async function loginStandard(registry: string, username: string, password
if (!password) {
throw new Error('Password required');
}
await loginExec(registry, username, password, scope);
await loginExec(registry, username, password, scope, retryArgs);
}
export async function loginECR(registry: string, username: string, password: string, scope?: string): Promise<void> {
export async function loginECR(registry: string, username: string, password: string, scope?: string, retryArgs?: context.RetryArgs): Promise<void> {
core.info(`Retrieving registries data through AWS SDK...`);
const regDatas = await aws.getRegistriesData(registry, username, password);
for (const regData of regDatas) {
await loginExec(regData.registry, regData.username, regData.password, scope);
await loginExec(regData.registry, regData.username, regData.password, scope, retryArgs);
}
}
async function loginExec(registry: string, username: string, password: string, scope?: string): Promise<void> {
async function loginExec(registry: string, username: string, password: string, scope?: string, retryArgs?: context.RetryArgs): Promise<void> {
let envs: {[key: string]: string} | undefined;
const configDir = context.scopeToConfigDir(registry, scope);
if (configDir !== '') {
@ -67,15 +103,24 @@ async function loginExec(registry: string, username: string, password: string, s
} else {
core.info(`Logging into ${registry}...`);
}
await Docker.getExecOutput(['login', '--password-stdin', '--username', username, registry], {
ignoreReturnCode: true,
silent: true,
input: Buffer.from(password),
env: envs
}).then(res => {
if (res.stderr.length > 0 && res.exitCode != 0) {
throw new Error(res.stderr.trim());
}
core.info('Login Succeeded!');
});
const retry = retryArgs || {attempts: 0, delayMs: 5000};
await withRetry(
async () => {
await Docker.getExecOutput(['login', '--password-stdin', '--username', username, registry], {
ignoreReturnCode: true,
silent: true,
input: Buffer.from(password),
env: envs
}).then(res => {
if (res.stderr.length > 0 && res.exitCode != 0) {
throw new Error(res.stderr.trim());
}
core.info('Login Succeeded!');
});
},
retry,
`Login to ${registry}`
);
}