Skip to content

Commit ec8a1d3

Browse files
authored
fix(merge): classify a 429 secondary rate limit as infra-scoped (#9795)
`classifyMergeFailure` reached the self-healing `infra` scope for a rate-limit window only via `status === 403`, but this repo's own GitHub client documents that a secondary limit surfaces as 403 OR 429 (client.ts:422). A 429 matched no status or message branch, so it fell through to the commit-scoped default; once the retry cap was burned the failure went terminal with no expiry, and the head-scoped block stranded a green, approved PR until the contributor pushed a commit they had no reason to push. A fleet-wide 429 window catches every in-flight merge at once. Add an exported `isRateLimitMessage` predicate and two branches, ahead of the terminal `403`: a `429` and a rate-limited `403` both classify `{ terminal: false, scope: "infra" }`, so the two spellings of one condition are treated identically and the block expires on INFRA_MERGE_BLOCK_TTL_MS and is re-probed autonomously — exactly what the module already documents. No other status's `terminal` changes; the commit-scoped fall-through default is untouched. Closes #9693
1 parent 122a283 commit ec8a1d3

3 files changed

Lines changed: 81 additions & 5 deletions

File tree

src/services/merge-failure.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ function isConvergenceForbiddenMessage(message: string): boolean {
7777
return /resource not accessible by integration|secondary rate limit|api rate limit|abuse detection/i.test(message);
7878
}
7979

80+
/** A GitHub rate-limit signal — the message GitHub attaches to a secondary/abuse or primary rate-limit
81+
* response. A merge failing on this is an infra-scoped, self-healing window, whether GitHub spelled it 403 or
82+
* 429 (src/github/client.ts treats both the same). Exported so the 403 and 429 branches share one definition
83+
* (#9693). */
84+
export function isRateLimitMessage(message: string): boolean {
85+
return /secondary rate limit|abuse|api rate limit exceeded/i.test(message);
86+
}
87+
8088
/** Read the HTTP status off an Octokit RequestError (it sets `.status`); undefined for non-HTTP errors. */
8189
function httpStatus(error: unknown): number | undefined {
8290
const status = (error as { status?: unknown } | null | undefined)?.status;
@@ -119,6 +127,13 @@ export function classifyMergeFailure(error: unknown): { terminal: boolean; reaso
119127
const message = errorMessage(error);
120128
const status = httpStatus(error);
121129
if (status === 401) return { terminal: true, scope: "infra", reason: `installation token rejected: App suspended or key rotated (401): ${message}` };
130+
// A secondary rate-limit window surfaces as 403 OR 429 (src/github/client.ts:422) and is fleet-wide + self-
131+
// healing — infra-scoped so it lapses on the TTL re-probe instead of stranding every in-flight merge on a
132+
// head-scoped block until an unrelated commit lands (#9693). The 429 arm sits with the other status branches;
133+
// the rate-limited-403 arm must precede the terminal `403` below so it is not swallowed by it.
134+
if (status === 429) return { terminal: false, scope: "infra", reason: `merge rate-limited (429 — secondary rate limit, self-healing window): ${message}` };
135+
if (status === 403 && isRateLimitMessage(message))
136+
return { terminal: false, scope: "infra", reason: `merge rate-limited (403 — secondary rate limit, self-healing window): ${message}` };
122137
if (status === 403 && isConvergenceForbiddenMessage(message))
123138
return { terminal: false, scope: "infra", reason: `merge forbidden for now (403 — branch protection or GitHub permission visibility may still be converging): ${message}` };
124139
if (status === 403) return { terminal: true, scope: "commit", reason: `merge forbidden (403): ${message}` };

test/unit/merge-block-recovery.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import { getPullRequest, markPullRequestMergeBlocked, bumpPullRequestMergeAttempt, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertInstallation } from "../../src/db/repositories";
3-
import { activeMergeBlockedSha, classifyMergeFailure, INFRA_MERGE_BLOCK_TTL_MS, isMergeBlockInEffect } from "../../src/services/merge-failure";
3+
import { activeMergeBlockedSha, classifyMergeFailure, INFRA_MERGE_BLOCK_TTL_MS, isMergeBlockInEffect, MERGE_RETRY_CAP } from "../../src/services/merge-failure";
44
import { AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, planAgentMaintenanceActions } from "../../src/settings/agent-actions";
55
import { createTestEnv } from "../helpers/d1";
66

@@ -115,6 +115,47 @@ describe("markPullRequestMergeBlocked persists the scope (#9012)", () => {
115115
});
116116
});
117117

118+
// #9693: a 429 secondary-rate-limit window that burns MERGE_RETRY_CAP must persist an INFRA-scoped (expiring)
119+
// block, not a commit-scoped one that strands the PR until an unrelated commit lands. handleMergeFailure derives
120+
// the expiry from classifyMergeFailure(...).scope, so we drive that composition exactly as the executor does.
121+
describe("a rate-limit-exhausted merge persists a self-healing infra block (#9693)", () => {
122+
const NOW = Date.parse("2026-07-26T12:00:00.000Z");
123+
const blockFor = (error: unknown) => {
124+
// Mirror handleMergeFailure's retry-cap path: on exhaustion the classified scope decides the expiry.
125+
const { scope, terminal } = classifyMergeFailure(error);
126+
// A 429/rate-limited failure is non-terminal, so it reaches the cap path and escalates there.
127+
expect(terminal).toBe(false);
128+
return scope === "infra" ? new Date(NOW + INFRA_MERGE_BLOCK_TTL_MS).toISOString() : undefined;
129+
};
130+
131+
it("persists a non-null, lapsing mergeBlockedUntil after MERGE_RETRY_CAP 429 failures", async () => {
132+
const env = createTestEnv();
133+
await seedPr(env, "sha-1");
134+
// Exhaust the retry budget on the same head, exactly as repeated 429 attempts would.
135+
for (let i = 0; i < MERGE_RETRY_CAP; i += 1) await bumpPullRequestMergeAttempt(env, "alice/repo", 5, "sha-1");
136+
const expiresAt = blockFor(httpError(429, "You have exceeded a secondary rate limit"));
137+
expect(expiresAt).not.toBeUndefined();
138+
await markPullRequestMergeBlocked(env, "alice/repo", 5, "sha-1", "merge could not complete after 5 attempt(s)", expiresAt);
139+
140+
const stored = await getPullRequest(env, "alice/repo", 5);
141+
expect(stored?.mergeBlockedUntil).not.toBeNull();
142+
// The block genuinely lapses on the TTL — no new commit required.
143+
expect(isMergeBlockInEffect(stored!, "sha-1", NOW + INFRA_MERGE_BLOCK_TTL_MS + 1)).toBe(false);
144+
});
145+
146+
it("keeps a 409 merge-conflict block commit-scoped (mergeBlockedUntil null), unchanged", async () => {
147+
const env = createTestEnv();
148+
await seedPr(env, "sha-1");
149+
// A 409 is terminal on the first failure — commit-scoped, no expiry.
150+
const { scope, terminal } = classifyMergeFailure(httpError(409, "Required status check is expected."));
151+
expect(terminal).toBe(true);
152+
const expiresAt = scope === "infra" ? new Date(NOW + INFRA_MERGE_BLOCK_TTL_MS).toISOString() : undefined;
153+
await markPullRequestMergeBlocked(env, "alice/repo", 5, "sha-1", "merge conflict (409)", expiresAt);
154+
155+
expect((await getPullRequest(env, "alice/repo", 5))?.mergeBlockedUntil ?? null).toBeNull();
156+
});
157+
});
158+
118159
// #9012 compounding bug: mergeAttemptCount's own schema and function docs promised "a new commit's attempts
119160
// start fresh once the row's head advances", but nothing reset it — bumpPullRequestMergeAttempt only scoped the
120161
// INCREMENT to the head. So once one head exhausted MERGE_RETRY_CAP, every later head was one-strike-terminal.

test/unit/merge-failure.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { classifyMergeFailure, isMergeConflictMessage, isNoNewBaseCommitsMessage, isWorkflowScopeRefusalMessage, MERGE_RETRY_CAP } from "../../src/services/merge-failure";
2+
import { classifyMergeFailure, isMergeConflictMessage, isNoNewBaseCommitsMessage, isRateLimitMessage, isWorkflowScopeRefusalMessage, MERGE_RETRY_CAP } from "../../src/services/merge-failure";
33

44
/** Build an Octokit-style RequestError: an Error carrying an HTTP `.status`. */
55
function httpError(status: number, message: string): Error {
@@ -36,13 +36,33 @@ describe("classifyMergeFailure", () => {
3636
});
3737

3838
it("retries GitHub's generic 403 merge rejection because branch protection can still converge", () => {
39-
for (const message of ["Resource not accessible by integration", "secondary rate limit", "API rate limit exceeded", "abuse detection mechanism triggered"]) {
39+
// A non-rate-limit convergence 403 (permission visibility still settling) — the converging arm.
40+
const result = classifyMergeFailure(httpError(403, "Resource not accessible by integration"));
41+
expect(result.terminal).toBe(false);
42+
expect(result.scope).toBe("infra");
43+
expect(result.reason).toMatch(/converging/i);
44+
});
45+
46+
it("classifies a 429 and a rate-limited 403 as an infra-scoped, self-healing window (#9693)", () => {
47+
// 429: no branch matched this before, so it fell through to a commit-scoped block that stranded the PR.
48+
const rateLimited429 = classifyMergeFailure(httpError(429, "You have exceeded a secondary rate limit"));
49+
expect(rateLimited429).toMatchObject({ terminal: false, scope: "infra" });
50+
expect(rateLimited429.reason).toMatch(/rate-limited/i);
51+
// 403 spellings of the same window are treated identically, before the terminal 403 branch.
52+
for (const message of ["secondary rate limit", "API rate limit exceeded", "abuse detection mechanism triggered"]) {
4053
const result = classifyMergeFailure(httpError(403, message));
41-
expect(result.terminal).toBe(false);
42-
expect(result.reason).toMatch(/converging/i);
54+
expect(result).toMatchObject({ terminal: false, scope: "infra" });
55+
expect(result.reason).toMatch(/rate-limited/i);
4356
}
4457
});
4558

59+
it("exposes isRateLimitMessage matching only rate-limit text (#9693)", () => {
60+
expect(isRateLimitMessage("You have exceeded a secondary rate limit")).toBe(true);
61+
expect(isRateLimitMessage("abuse detection mechanism triggered")).toBe(true);
62+
expect(isRateLimitMessage("API rate limit exceeded")).toBe(true);
63+
expect(isRateLimitMessage("Repository does not allow squash merges")).toBe(false);
64+
});
65+
4666
it("treats non-convergence 403s, 409, and real merge-conflict text as terminal", () => {
4767
expect(classifyMergeFailure(httpError(403, "Repository does not allow squash merges")).terminal).toBe(true);
4868
expect(classifyMergeFailure(httpError(409, "Required status check is expected.")).terminal).toBe(true);

0 commit comments

Comments
 (0)