Skip to content

Commit 5f6978f

Browse files
authored
fix: retry post-creation API calls on 422 eventual consistency errors (#4356)
Add retry logic to handle GitHub API eventual consistency errors that can occur after creating a new pull request. Follow-up API calls for milestones, labels, assignees, and reviewers may fail with a 422 "Could not resolve to a node" error before the PR is fully propagated. - Add generic `retryWithBackoff` helper in `src/utils.ts` with exponential backoff (default 2 retries, starting at 1s delay) - Wrap post-creation API calls in `src/github-helper.ts` with `withRetryForNewPr()`, which only retries for newly created PRs - Use `@octokit/request-error` `RequestError` type for precise error matching (status 422 + "Could not resolve to a node" message) - Add unit tests for `retryWithBackoff` covering success, retry, exhaustion, and non-retryable error scenarios - Update `dist/index.js` bundle and `package.json` dependencies
1 parent d32e88d commit 5f6978f

File tree

6 files changed

+199
-30
lines changed

6 files changed

+199
-30
lines changed

__test__/utils.unit.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,71 @@ describe('utils tests', () => {
118118
}
119119
})
120120
})
121+
122+
describe('retryWithBackoff', () => {
123+
const makeConsistencyError = () => {
124+
const error = new Error(
125+
'Validation Failed: "Could not resolve to a node with the global id of \'PR_abc123\'."'
126+
)
127+
;(error as any).status = 422
128+
return error
129+
}
130+
131+
const shouldRetry = (e: unknown): boolean =>
132+
e instanceof Error &&
133+
(e as any).status === 422 &&
134+
e.message.includes('Could not resolve to a node')
135+
136+
test('succeeds on first attempt without retrying', async () => {
137+
const fn = jest.fn().mockResolvedValue('success')
138+
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
139+
expect(result).toBe('success')
140+
expect(fn).toHaveBeenCalledTimes(1)
141+
})
142+
143+
test('retries on eventual consistency 422 and succeeds', async () => {
144+
const fn = jest
145+
.fn()
146+
.mockRejectedValueOnce(makeConsistencyError())
147+
.mockResolvedValue('success')
148+
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
149+
expect(result).toBe('success')
150+
expect(fn).toHaveBeenCalledTimes(2)
151+
})
152+
153+
test('exhausts retries on persistent 422 and throws', async () => {
154+
const fn = jest.fn().mockRejectedValue(makeConsistencyError())
155+
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
156+
'Could not resolve to a node'
157+
)
158+
expect(fn).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
159+
})
160+
161+
test('does not retry on non-422 errors', async () => {
162+
const error = new Error('Forbidden')
163+
;(error as any).status = 403
164+
const fn = jest.fn().mockRejectedValue(error)
165+
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
166+
'Forbidden'
167+
)
168+
expect(fn).toHaveBeenCalledTimes(1)
169+
})
170+
171+
test('does not retry on 422 without the consistency error message', async () => {
172+
const error = new Error('Validation Failed: invalid label')
173+
;(error as any).status = 422
174+
const fn = jest.fn().mockRejectedValue(error)
175+
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
176+
'Validation Failed: invalid label'
177+
)
178+
expect(fn).toHaveBeenCalledTimes(1)
179+
})
180+
181+
test('does not retry on plain Error objects', async () => {
182+
const fn = jest.fn().mockRejectedValue(new Error('Something broke'))
183+
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
184+
'Something broke'
185+
)
186+
expect(fn).toHaveBeenCalledTimes(1)
187+
})
188+
})

dist/index.js

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13751375
Object.defineProperty(exports, "__esModule", ({ value: true }));
13761376
exports.GitHubHelper = void 0;
13771377
const core = __importStar(__nccwpck_require__(7484));
1378+
const request_error_1 = __nccwpck_require__(1015);
13781379
const octokit_client_1 = __nccwpck_require__(3489);
13791380
const p_limit_1 = __importDefault(__nccwpck_require__(7989));
13801381
const utils = __importStar(__nccwpck_require__(9277));
@@ -1501,20 +1502,28 @@ class GitHubHelper {
15011502
return __awaiter(this, void 0, void 0, function* () {
15021503
// Create or update the pull request
15031504
const pull = yield this.createOrUpdate(inputs, baseRepository, headRepository);
1505+
// After creating a new PR, follow-up API calls can fail with a 422
1506+
// "Could not resolve to a node" error due to GitHub API eventual
1507+
// consistency. Wrap post-creation calls with targeted retry logic.
1508+
// See: https://github.com/peter-evans/create-pull-request/issues/4321
1509+
const isEventualConsistencyError = (e) => e instanceof request_error_1.RequestError &&
1510+
e.status === 422 &&
1511+
e.message.includes('Could not resolve to a node');
1512+
const withRetryForNewPr = (fn) => pull.created ? utils.retryWithBackoff(fn, isEventualConsistencyError) : fn();
15041513
// Apply milestone
15051514
if (inputs.milestone) {
15061515
core.info(`Applying milestone '${inputs.milestone}'`);
1507-
yield this.octokit.rest.issues.update(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, milestone: inputs.milestone }));
1516+
yield withRetryForNewPr(() => this.octokit.rest.issues.update(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, milestone: inputs.milestone })));
15081517
}
15091518
// Apply labels
15101519
if (inputs.labels.length > 0) {
15111520
core.info(`Applying labels '${inputs.labels}'`);
1512-
yield this.octokit.rest.issues.addLabels(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, labels: inputs.labels }));
1521+
yield withRetryForNewPr(() => this.octokit.rest.issues.addLabels(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, labels: inputs.labels })));
15131522
}
15141523
// Apply assignees
15151524
if (inputs.assignees.length > 0) {
15161525
core.info(`Applying assignees '${inputs.assignees}'`);
1517-
yield this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees }));
1526+
yield withRetryForNewPr(() => this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees })));
15181527
}
15191528
// Request reviewers and team reviewers
15201529
const requestReviewersParams = {};
@@ -1529,7 +1538,7 @@ class GitHubHelper {
15291538
}
15301539
if (Object.keys(requestReviewersParams).length > 0) {
15311540
try {
1532-
yield this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams));
1541+
yield withRetryForNewPr(() => this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams)));
15331542
}
15341543
catch (e) {
15351544
if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) {
@@ -1900,6 +1909,15 @@ var __importStar = (this && this.__importStar) || (function () {
19001909
return result;
19011910
};
19021911
})();
1912+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
1913+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
1914+
return new (P || (P = Promise))(function (resolve, reject) {
1915+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
1916+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
1917+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
1918+
step((generator = generator.apply(thisArg, _arguments || [])).next());
1919+
});
1920+
};
19031921
Object.defineProperty(exports, "__esModule", ({ value: true }));
19041922
exports.isSelfHosted = void 0;
19051923
exports.getInputAsArray = getInputAsArray;
@@ -1913,6 +1931,7 @@ exports.parseDisplayNameEmail = parseDisplayNameEmail;
19131931
exports.fileExistsSync = fileExistsSync;
19141932
exports.readFile = readFile;
19151933
exports.getErrorMessage = getErrorMessage;
1934+
exports.retryWithBackoff = retryWithBackoff;
19161935
const core = __importStar(__nccwpck_require__(7484));
19171936
const fs = __importStar(__nccwpck_require__(9896));
19181937
const path = __importStar(__nccwpck_require__(6928));
@@ -2014,6 +2033,26 @@ const isSelfHosted = () => process.env['RUNNER_ENVIRONMENT'] !== 'github-hosted'
20142033
(process.env['AGENT_ISSELFHOSTED'] === '1' ||
20152034
process.env['AGENT_ISSELFHOSTED'] === undefined);
20162035
exports.isSelfHosted = isSelfHosted;
2036+
function retryWithBackoff(fn_1, shouldRetry_1) {
2037+
return __awaiter(this, arguments, void 0, function* (fn, shouldRetry, maxRetries = 2, delayMs = 1000) {
2038+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
2039+
try {
2040+
return yield fn();
2041+
}
2042+
catch (e) {
2043+
if (attempt < maxRetries && shouldRetry(e)) {
2044+
const delay = delayMs * Math.pow(2, attempt);
2045+
core.info(`Request failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...`);
2046+
yield new Promise(resolve => setTimeout(resolve, delay));
2047+
}
2048+
else {
2049+
throw e;
2050+
}
2051+
}
2052+
}
2053+
throw new Error('Unexpected: retry loop exited without return or throw');
2054+
});
2055+
}
20172056

20182057

20192058
/***/ }),
@@ -32825,7 +32864,7 @@ async function fetchWrapper(requestOptions) {
3282532864
}
3282632865
}
3282732866
}
32828-
const requestError = new dist_src/* RequestError */.G(message, 500, {
32867+
const requestError = new dist_src.RequestError(message, 500, {
3282932868
request: requestOptions
3283032869
});
3283132870
requestError.cause = error;
@@ -32857,21 +32896,21 @@ async function fetchWrapper(requestOptions) {
3285732896
if (status < 400) {
3285832897
return octokitResponse;
3285932898
}
32860-
throw new dist_src/* RequestError */.G(fetchResponse.statusText, status, {
32899+
throw new dist_src.RequestError(fetchResponse.statusText, status, {
3286132900
response: octokitResponse,
3286232901
request: requestOptions
3286332902
});
3286432903
}
3286532904
if (status === 304) {
3286632905
octokitResponse.data = await getResponseData(fetchResponse);
32867-
throw new dist_src/* RequestError */.G("Not modified", status, {
32906+
throw new dist_src.RequestError("Not modified", status, {
3286832907
response: octokitResponse,
3286932908
request: requestOptions
3287032909
});
3287132910
}
3287232911
if (status >= 400) {
3287332912
octokitResponse.data = await getResponseData(fetchResponse);
32874-
throw new dist_src/* RequestError */.G(toErrorMessage(octokitResponse.data), status, {
32913+
throw new dist_src.RequestError(toErrorMessage(octokitResponse.data), status, {
3287532914
response: octokitResponse,
3287632915
request: requestOptions
3287732916
});
@@ -36220,7 +36259,7 @@ async function requestWithGraphqlErrorHandling(state, octokit, request, options)
3622036259
if (response.data && response.data.errors && response.data.errors.length > 0 && /Something went wrong while executing your query/.test(
3622136260
response.data.errors[0].message
3622236261
)) {
36223-
const error = new _octokit_request_error__WEBPACK_IMPORTED_MODULE_1__/* .RequestError */ .G(response.data.errors[0].message, 500, {
36262+
const error = new _octokit_request_error__WEBPACK_IMPORTED_MODULE_1__.RequestError(response.data.errors[0].message, 500, {
3622436263
request: options,
3622536264
response
3622636265
});
@@ -36505,8 +36544,9 @@ throttling.triggersNotification = triggersNotification;
3650536544
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __nccwpck_require__) => {
3650636545

3650736546
"use strict";
36547+
__nccwpck_require__.r(__webpack_exports__);
3650836548
/* harmony export */ __nccwpck_require__.d(__webpack_exports__, {
36509-
/* harmony export */ G: () => (/* binding */ RequestError)
36549+
/* harmony export */ RequestError: () => (/* binding */ RequestError)
3651036550
/* harmony export */ });
3651136551
class RequestError extends Error {
3651236552
name;

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@octokit/plugin-rest-endpoint-methods": "^13.5.0",
4040
"@octokit/plugin-retry": "^7.2.1",
4141
"@octokit/plugin-throttling": "^9.6.1",
42+
"@octokit/request-error": "^6.1.8",
4243
"node-fetch-native": "^1.6.7",
4344
"p-limit": "^6.2.0",
4445
"uuid": "^9.0.1"

0 commit comments

Comments
 (0)