From 1f5b01bb5dd21b4f601d274151ed0f67e497d9fa Mon Sep 17 00:00:00 2001 From: Daniel Beilin Date: Wed, 18 Mar 2026 13:47:41 +0200 Subject: [PATCH] fix: retry post-creation API calls on 422 to handle GitHub eventual consistency After creating a PR, follow-up API calls (labels, milestone, assignees, reviewers) can fail with a 422 "Could not resolve to a node" error due to GitHub API eventual consistency. This adds targeted retry logic that only activates for newly created PRs, retrying 422 errors up to 2 times with a 1-second delay between attempts. Fixes #4321 Co-Authored-By: Claude Opus 4.6 --- __test__/retryWithBackoff.unit.test.ts | 57 +++++++++++++++++++++++++ dist/index.js | 44 +++++++++++++++++-- src/github-helper.ts | 58 +++++++++++++++++--------- src/utils.ts | 23 ++++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 __test__/retryWithBackoff.unit.test.ts diff --git a/__test__/retryWithBackoff.unit.test.ts b/__test__/retryWithBackoff.unit.test.ts new file mode 100644 index 0000000000..39fa081392 --- /dev/null +++ b/__test__/retryWithBackoff.unit.test.ts @@ -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 + }) +}) diff --git a/dist/index.js b/dist/index.js index 56100057fa..3f861f51cd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 = {}; @@ -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)) { @@ -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; @@ -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)); @@ -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 + }); +} /***/ }), diff --git a/src/github-helper.ts b/src/github-helper.ts index 85439dddf0..48b92a765f 100644 --- a/src/github-helper.ts +++ b/src/github-helper.ts @@ -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 = (fn: () => Promise): Promise => + 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 @@ -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( diff --git a/src/utils.ts b/src/utils.ts index 6f822d237d..8ec8d645c8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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( + fn: () => Promise, + shouldRetry: (error: unknown) => boolean, + maxRetries = 2, + delayMs = 1000 +): Promise { + 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 +}