From edf5fd19920e3480eb396ddb4578ed505c91e1c2 Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 03:37:24 +0800 Subject: [PATCH] fix(miner): treat an absent x-ratelimit-remaining header as unknown, not a 0 budget (#9678) recordRateLimit did `Number(response.headers.get("x-ratelimit-remaining"))`, but headers.get() returns null for an ABSENT header and Number(null) === 0 (which Number.isFinite accepts). So a forge/reverse-proxy that omits the header made the miner record a remaining budget of 0, and the running Math.min pinned it at 0 for the whole run -- resolveThrottledConcurrency then serialized the entire fan-out to a single in-flight request against a budget that was never reported, defeating the "unknown budget runs at full concurrency" contract discovery-throttle.ts documents. Read the raw header and skip a null or blank/whitespace-only value before converting, matching http-retry.ts's own `remaining != null` guard. A genuinely-present "0" (or any finite number) is still recorded; a present-but-non-numeric value is still skipped by Number.isFinite. The x-ratelimit-reset branch is unchanged. Tests: absent header -> rateLimitRemaining stays null (not 0); a blank header is skipped; a present "0" is still recorded. Closes #9678 Co-Authored-By: Claude Opus 4.8 --- .../loopover-miner/lib/opportunity-fanout.ts | 20 +++++--- test/unit/miner-opportunity-fanout.test.ts | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/loopover-miner/lib/opportunity-fanout.ts b/packages/loopover-miner/lib/opportunity-fanout.ts index d4172de922..89a462efc6 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.ts +++ b/packages/loopover-miner/lib/opportunity-fanout.ts @@ -208,12 +208,20 @@ function repoPath(forge: ForgeConfig, target: Target, suffix: string): string { } function recordRateLimit(summary: RateLimitSummary, response: Response): void { - const remaining = Number(response.headers.get("x-ratelimit-remaining")); - if (Number.isFinite(remaining)) { - summary.rateLimitRemaining = - summary.rateLimitRemaining === null - ? remaining - : Math.min(summary.rateLimitRemaining, remaining); + // #9678: Headers.get() returns null for an ABSENT header and Number(null) === 0 (which Number.isFinite + // accepts) -- so a forge/proxy that omits x-ratelimit-remaining used to record a budget of 0 and pin the + // whole fan-out to serial concurrency. Read the raw value and skip a null/blank header before converting, + // matching http-retry.ts's `remaining != null` guard; a genuinely-present "0" (or any finite number) is + // still recorded, and a present-but-non-numeric value is still skipped by Number.isFinite. + const rawRemaining = response.headers.get("x-ratelimit-remaining"); + if (rawRemaining !== null && rawRemaining.trim() !== "") { + const remaining = Number(rawRemaining); + if (Number.isFinite(remaining)) { + summary.rateLimitRemaining = + summary.rateLimitRemaining === null + ? remaining + : Math.min(summary.rateLimitRemaining, remaining); + } } const resetSeconds = Number(response.headers.get("x-ratelimit-reset")); if (Number.isFinite(resetSeconds) && resetSeconds > 0) { diff --git a/test/unit/miner-opportunity-fanout.test.ts b/test/unit/miner-opportunity-fanout.test.ts index 01d64eac24..6cbc26390a 100644 --- a/test/unit/miner-opportunity-fanout.test.ts +++ b/test/unit/miner-opportunity-fanout.test.ts @@ -479,6 +479,56 @@ describe("fetchCandidateIssues (#2307)", () => { expect(result.rateLimitRemaining).toBe(42); }); + it("#9678: an ABSENT x-ratelimit-remaining header records no budget (null), not 0", async () => { + // A forge/proxy that omits the header: headers.get() is null and Number(null) is 0 (finite) -- before + // #9678 this pinned rateLimitRemaining to 0 and serialized the whole fan-out against a budget never reported. + const bareContent = () => + Response.json({ type: "file", encoding: "base64", content: Buffer.from("Contributions welcome.", "utf8").toString("base64") }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return Response.json({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return bareContent(); + if (url.includes("/issues?")) return Response.json([issue(3)]); + return Response.json({}, { status: 404 }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { apiBaseUrl: API }); + + expect(result.rateLimitRemaining).toBeNull(); + }); + + it("#9678: a blank/whitespace-only x-ratelimit-remaining header is skipped (stays null), not recorded as 0", async () => { + const headers = { "x-ratelimit-remaining": " " }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return Response.json({}, { status: 404, headers }); + if (url.endsWith("/contents/CONTRIBUTING.md")) + return Response.json({ type: "file", encoding: "base64", content: Buffer.from("Contributions welcome.", "utf8").toString("base64") }, { headers }); + if (url.includes("/issues?")) return Response.json([issue(3)], { headers }); + return Response.json({}, { status: 404, headers }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { apiBaseUrl: API }); + + expect(result.rateLimitRemaining).toBeNull(); + }); + + it("#9678: a genuinely-present '0' x-ratelimit-remaining is still recorded (real exhaustion is not lost)", async () => { + const headers = { "x-ratelimit-remaining": "0" }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return Response.json({}, { status: 404, headers }); + if (url.endsWith("/contents/CONTRIBUTING.md")) + return Response.json({ type: "file", encoding: "base64", content: Buffer.from("Contributions welcome.", "utf8").toString("base64") }, { headers }); + if (url.includes("/issues?")) return Response.json([issue(3)], { headers }); + return Response.json({}, { status: 404, headers }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { apiBaseUrl: API }); + + expect(result.rateLimitRemaining).toBe(0); + }); + it("treats a malformed (array, not object) policy-doc payload as absent content, and passes through non-base64-encoded content unchanged", async () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = String(input);