From f2248d03e574332c604d0b8966e3b35f0caee86c Mon Sep 17 00:00:00 2001 From: "Vincent, Robert" Date: Mon, 11 May 2026 11:50:08 -0400 Subject: [PATCH] Handle checkout 500 retries with backoff Switch the shared retry helper from randomized delays to exponential backoff so transient GitHub 500 errors are retried predictably. Add coverage for the backoff sequence and regenerate the bundled dist output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __test__/retry-helper.test.ts | 66 ++++++++++++++++++++++++++++++----- dist/index.js | 10 +++--- src/retry-helper.ts | 13 +++---- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/__test__/retry-helper.test.ts b/__test__/retry-helper.test.ts index a5d3f79..02c7ed3 100644 --- a/__test__/retry-helper.test.ts +++ b/__test__/retry-helper.test.ts @@ -2,7 +2,6 @@ import * as core from '@actions/core' import {RetryHelper} from '../lib/retry-helper' let info: string[] -let retryHelper: any describe('retry-helper tests', () => { beforeAll(() => { @@ -10,8 +9,6 @@ describe('retry-helper tests', () => { jest.spyOn(core, 'info').mockImplementation((message: string) => { info.push(message) }) - - retryHelper = new RetryHelper(3, 0, 0) }) beforeEach(() => { @@ -25,14 +22,22 @@ describe('retry-helper tests', () => { }) it('first attempt succeeds', async () => { + const retryHelper: any = new RetryHelper(3, 1, 10) + const sleep = jest.fn().mockResolvedValue(undefined) + retryHelper.sleep = sleep + const actual = await retryHelper.execute(async () => { return 'some result' }) expect(actual).toBe('some result') expect(info).toHaveLength(0) + expect(sleep).not.toHaveBeenCalled() }) it('second attempt succeeds', async () => { + const retryHelper: any = new RetryHelper(3, 1, 10) + const sleep = jest.fn().mockResolvedValue(undefined) + retryHelper.sleep = sleep let attempts = 0 const actual = await retryHelper.execute(() => { if (++attempts == 1) { @@ -45,10 +50,15 @@ describe('retry-helper tests', () => { expect(actual).toBe('some result') expect(info).toHaveLength(2) expect(info[0]).toBe('some error') - expect(info[1]).toMatch(/Waiting .+ seconds before trying again/) + expect(info[1]).toBe('Waiting 1 seconds before trying again') + expect(sleep).toHaveBeenCalledTimes(1) + expect(sleep).toHaveBeenCalledWith(1) }) it('third attempt succeeds', async () => { + const retryHelper: any = new RetryHelper(3, 1, 10) + const sleep = jest.fn().mockResolvedValue(undefined) + retryHelper.sleep = sleep let attempts = 0 const actual = await retryHelper.execute(() => { if (++attempts < 3) { @@ -61,12 +71,18 @@ describe('retry-helper tests', () => { expect(actual).toBe('some result') expect(info).toHaveLength(4) expect(info[0]).toBe('some error 1') - expect(info[1]).toMatch(/Waiting .+ seconds before trying again/) + expect(info[1]).toBe('Waiting 1 seconds before trying again') expect(info[2]).toBe('some error 2') - expect(info[3]).toMatch(/Waiting .+ seconds before trying again/) + expect(info[3]).toBe('Waiting 2 seconds before trying again') + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenNthCalledWith(1, 1) + expect(sleep).toHaveBeenNthCalledWith(2, 2) }) it('all attempts fail succeeds', async () => { + const retryHelper: any = new RetryHelper(3, 1, 10) + const sleep = jest.fn().mockResolvedValue(undefined) + retryHelper.sleep = sleep let attempts = 0 let error: Error = null as unknown as Error try { @@ -80,8 +96,42 @@ describe('retry-helper tests', () => { expect(attempts).toBe(3) expect(info).toHaveLength(4) expect(info[0]).toBe('some error 1') - expect(info[1]).toMatch(/Waiting .+ seconds before trying again/) + expect(info[1]).toBe('Waiting 1 seconds before trying again') expect(info[2]).toBe('some error 2') - expect(info[3]).toMatch(/Waiting .+ seconds before trying again/) + expect(info[3]).toBe('Waiting 2 seconds before trying again') + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenNthCalledWith(1, 1) + expect(sleep).toHaveBeenNthCalledWith(2, 2) + }) + + it('server-side 500 errors are retried with exponential backoff', async () => { + const retryHelper: any = new RetryHelper(4, 2, 10) + const sleep = jest.fn().mockResolvedValue(undefined) + retryHelper.sleep = sleep + let attempts = 0 + + const actual = await retryHelper.execute(() => { + if (++attempts < 3) { + const error: Error & {status?: number} = new Error( + `server error ${attempts}` + ) + error.status = 500 + throw error + } + + return Promise.resolve('some result') + }) + + expect(actual).toBe('some result') + expect(attempts).toBe(3) + expect(info).toEqual([ + 'server error 1', + 'Waiting 2 seconds before trying again', + 'server error 2', + 'Waiting 4 seconds before trying again' + ]) + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenNthCalledWith(1, 2) + expect(sleep).toHaveBeenNthCalledWith(2, 4) }) }) diff --git a/dist/index.js b/dist/index.js index 57729b2..84cfd27 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2567,7 +2567,7 @@ class RetryHelper { core.info(err === null || err === void 0 ? void 0 : err.message); } // Sleep - const seconds = this.getSleepAmount(); + const seconds = this.getSleepAmount(attempt); core.info(`Waiting ${seconds} seconds before trying again`); yield this.sleep(seconds); attempt++; @@ -2576,9 +2576,11 @@ class RetryHelper { return yield action(); }); } - getSleepAmount() { - return (Math.floor(Math.random() * (this.maxSeconds - this.minSeconds + 1)) + - this.minSeconds); + getSleepAmount(attempt) { + if (this.minSeconds === 0) { + return 0; + } + return Math.min(this.minSeconds * Math.pow(2, attempt - 1), this.maxSeconds); } sleep(seconds) { return __awaiter(this, void 0, void 0, function* () { diff --git a/src/retry-helper.ts b/src/retry-helper.ts index 323e75d..517b23e 100644 --- a/src/retry-helper.ts +++ b/src/retry-helper.ts @@ -33,7 +33,7 @@ export class RetryHelper { } // Sleep - const seconds = this.getSleepAmount() + const seconds = this.getSleepAmount(attempt) core.info(`Waiting ${seconds} seconds before trying again`) await this.sleep(seconds) attempt++ @@ -43,11 +43,12 @@ export class RetryHelper { return await action() } - private getSleepAmount(): number { - return ( - Math.floor(Math.random() * (this.maxSeconds - this.minSeconds + 1)) + - this.minSeconds - ) + private getSleepAmount(attempt: number): number { + if (this.minSeconds === 0) { + return 0 + } + + return Math.min(this.minSeconds * Math.pow(2, attempt - 1), this.maxSeconds) } private async sleep(seconds: number): Promise {