Skip to content
Merged
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
20 changes: 14 additions & 6 deletions packages/loopover-miner/lib/opportunity-fanout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions test/unit/miner-opportunity-fanout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down