Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions __test__/retryWithBackoff.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as utils from '../lib/utils'

// Mock @actions/core to avoid side effects in tests
jest.mock('@actions/core', () => ({
info: jest.fn(),
debug: jest.fn()
}))

describe('retryWithBackoff', () => {
const shouldRetry = (e: unknown) =>
typeof e === 'object' && e !== null && (e as any).status === 422

test('succeeds on first attempt without retrying', async () => {
const fn = jest.fn().mockResolvedValue('success')
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(1)
})

test('retries on 422 and succeeds on second attempt', async () => {
const error = Object.assign(new Error('Validation Failed'), {status: 422})
const fn = jest
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success')
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})

test('exhausts retries on persistent 422 and throws', async () => {
const error = Object.assign(new Error('Validation Failed'), {status: 422})
const fn = jest.fn().mockRejectedValue(error)
await expect(
utils.retryWithBackoff(fn, shouldRetry, 2, 1)
).rejects.toThrow('Validation Failed')
expect(fn).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
})

test('does not retry on non-422 errors', async () => {
const error = Object.assign(new Error('Forbidden'), {status: 403})
const fn = jest.fn().mockRejectedValue(error)
await expect(
utils.retryWithBackoff(fn, shouldRetry, 2, 1)
).rejects.toThrow('Forbidden')
expect(fn).toHaveBeenCalledTimes(1)
})

test('retries up to maxRetries times before throwing', async () => {
const error = Object.assign(new Error('Validation Failed'), {status: 422})
const fn = jest.fn().mockRejectedValue(error)
await expect(
utils.retryWithBackoff(fn, shouldRetry, 3, 1)
).rejects.toThrow('Validation Failed')
expect(fn).toHaveBeenCalledTimes(4) // 1 initial + 3 retries
})
})
44 changes: 40 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1501,20 +1501,27 @@ class GitHubHelper {
return __awaiter(this, void 0, void 0, function* () {
// Create or update the pull request
const pull = yield this.createOrUpdate(inputs, baseRepository, headRepository);
// Helper to conditionally retry on 422 for newly created PRs.
// GitHub's API may not immediately resolve the PR node after creation,
// causing 422 errors on follow-up requests (see issue #4321).
const isRetryable422 = (e) => typeof e === 'object' && e !== null && e.status === 422;
const maybeRetry = (fn) => pull.created
? utils.retryWithBackoff(fn, isRetryable422)
: fn();
// Apply milestone
if (inputs.milestone) {
core.info(`Applying milestone '${inputs.milestone}'`);
yield this.octokit.rest.issues.update(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, milestone: inputs.milestone }));
yield maybeRetry(() => this.octokit.rest.issues.update(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, milestone: inputs.milestone })));
}
// Apply labels
if (inputs.labels.length > 0) {
core.info(`Applying labels '${inputs.labels}'`);
yield this.octokit.rest.issues.addLabels(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, labels: inputs.labels }));
yield maybeRetry(() => this.octokit.rest.issues.addLabels(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, labels: inputs.labels })));
}
// Apply assignees
if (inputs.assignees.length > 0) {
core.info(`Applying assignees '${inputs.assignees}'`);
yield this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees }));
yield maybeRetry(() => this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees })));
}
// Request reviewers and team reviewers
const requestReviewersParams = {};
Expand All @@ -1529,7 +1536,7 @@ class GitHubHelper {
}
if (Object.keys(requestReviewersParams).length > 0) {
try {
yield this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams));
yield maybeRetry(() => this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams)));
}
catch (e) {
if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) {
Expand Down Expand Up @@ -1900,6 +1907,15 @@ var __importStar = (this && this.__importStar) || (function () {
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.isSelfHosted = void 0;
exports.getInputAsArray = getInputAsArray;
Expand All @@ -1913,6 +1929,7 @@ exports.parseDisplayNameEmail = parseDisplayNameEmail;
exports.fileExistsSync = fileExistsSync;
exports.readFile = readFile;
exports.getErrorMessage = getErrorMessage;
exports.retryWithBackoff = retryWithBackoff;
const core = __importStar(__nccwpck_require__(7484));
const fs = __importStar(__nccwpck_require__(9896));
const path = __importStar(__nccwpck_require__(6928));
Expand Down Expand Up @@ -2014,6 +2031,25 @@ const isSelfHosted = () => process.env['RUNNER_ENVIRONMENT'] !== 'github-hosted'
(process.env['AGENT_ISSELFHOSTED'] === '1' ||
process.env['AGENT_ISSELFHOSTED'] === undefined);
exports.isSelfHosted = isSelfHosted;
function retryWithBackoff(fn_1, shouldRetry_1) {
return __awaiter(this, arguments, void 0, function* (fn, shouldRetry, maxRetries = 2, delayMs = 1000) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return yield fn();
}
catch (e) {
if (attempt < maxRetries && shouldRetry(e)) {
core.info(`Request failed, retrying (attempt ${attempt + 2} of ${maxRetries + 1})...`);
yield new Promise(resolve => setTimeout(resolve, delayMs));
}
else {
throw e;
}
}
}
throw new Error('Unexpected retry failure'); // unreachable
});
}


/***/ }),
Expand Down
58 changes: 38 additions & 20 deletions src/github-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,32 +209,48 @@ export class GitHubHelper {
headRepository
)

// Helper to conditionally retry on 422 for newly created PRs.
// GitHub's API may not immediately resolve the PR node after creation,
// causing 422 errors on follow-up requests (see issue #4321).
const isRetryable422 = (e: unknown): boolean =>
typeof e === 'object' && e !== null && (e as any).status === 422
const maybeRetry = <T>(fn: () => Promise<T>): Promise<T> =>
pull.created
? utils.retryWithBackoff(fn, isRetryable422)
: fn()

// Apply milestone
if (inputs.milestone) {
core.info(`Applying milestone '${inputs.milestone}'`)
await this.octokit.rest.issues.update({
...this.parseRepository(baseRepository),
issue_number: pull.number,
milestone: inputs.milestone
})
await maybeRetry(() =>
this.octokit.rest.issues.update({
...this.parseRepository(baseRepository),
issue_number: pull.number,
milestone: inputs.milestone
})
)
}
// Apply labels
if (inputs.labels.length > 0) {
core.info(`Applying labels '${inputs.labels}'`)
await this.octokit.rest.issues.addLabels({
...this.parseRepository(baseRepository),
issue_number: pull.number,
labels: inputs.labels
})
await maybeRetry(() =>
this.octokit.rest.issues.addLabels({
...this.parseRepository(baseRepository),
issue_number: pull.number,
labels: inputs.labels
})
)
}
// Apply assignees
if (inputs.assignees.length > 0) {
core.info(`Applying assignees '${inputs.assignees}'`)
await this.octokit.rest.issues.addAssignees({
...this.parseRepository(baseRepository),
issue_number: pull.number,
assignees: inputs.assignees
})
await maybeRetry(() =>
this.octokit.rest.issues.addAssignees({
...this.parseRepository(baseRepository),
issue_number: pull.number,
assignees: inputs.assignees
})
)
}

// Request reviewers and team reviewers
Expand All @@ -250,11 +266,13 @@ export class GitHubHelper {
}
if (Object.keys(requestReviewersParams).length > 0) {
try {
await this.octokit.rest.pulls.requestReviewers({
...this.parseRepository(baseRepository),
pull_number: pull.number,
...requestReviewersParams
})
await maybeRetry(() =>
this.octokit.rest.pulls.requestReviewers({
...this.parseRepository(baseRepository),
pull_number: pull.number,
...requestReviewersParams
})
)
} catch (e) {
if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) {
core.error(
Expand Down
23 changes: 23 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,26 @@ export const isSelfHosted = (): boolean =>
process.env['RUNNER_ENVIRONMENT'] !== 'github-hosted' &&
(process.env['AGENT_ISSELFHOSTED'] === '1' ||
process.env['AGENT_ISSELFHOSTED'] === undefined)

export async function retryWithBackoff<T>(
fn: () => Promise<T>,
shouldRetry: (error: unknown) => boolean,
maxRetries = 2,
delayMs = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (e) {
if (attempt < maxRetries && shouldRetry(e)) {
core.info(
`Request failed, retrying (attempt ${attempt + 2} of ${maxRetries + 1})...`
)
await new Promise(resolve => setTimeout(resolve, delayMs))
} else {
throw e
}
}
}
throw new Error('Unexpected retry failure') // unreachable
}