From 0cd8d80a946affe82dc3c570b6c9a0b0edc99115 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Fri, 23 Oct 2026 08:54:51 +0300 Subject: [PATCH] fix(github-plugin): retry secondary rate limits, not only primary ones fetchWithRateLimit returned a 403 as soon as x-ratelimit-remaining was above zero, before looking at retry-after. That is the shape of a GitHub secondary rate limit (too many concurrent requests or too many points per minute): 403 or 429, a retry-after header, and a primary budget that is still positive. Those answers were returned to the caller as if they were permission errors, so a burst of imports or a bulk extract run failed on the first secondary limit instead of waiting the seconds GitHub asked for. The function's own docstring already promised '403 + retry-after'. A 403 is now treated as a permission answer only when it has budget left and no retry-after. The wait follows GitHub's documented order: retry-after when present, the reset time when remaining is 0, otherwise one minute. Checked with a stubbed fetch: a 403 with retry-after: 1 and remaining 4999 followed by 200 now takes two attempts and returns 200 (one attempt and 403 before); a 403 with remaining 4999 and no retry-after still returns at once; a 403 with remaining 0 still waits for the reset. --- apps/cursor/src/lib/github-plugin/parse.ts | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/cursor/src/lib/github-plugin/parse.ts b/apps/cursor/src/lib/github-plugin/parse.ts index 6fb62149..4f175a72 100644 --- a/apps/cursor/src/lib/github-plugin/parse.ts +++ b/apps/cursor/src/lib/github-plugin/parse.ts @@ -154,19 +154,34 @@ export async function fetchWithRateLimit( if (res.status !== 429 && res.status !== 403) return res; - // Distinguish "rate-limited" 403 from "forbidden" 403 by checking the - // X-RateLimit-Remaining header. A real auth/permission 403 has remaining > 0. + const retryAfterHeader = res.headers.get("retry-after"); + const retryAfter = Number(retryAfterHeader ?? ""); const remaining = Number(res.headers.get("x-ratelimit-remaining") ?? ""); - if (res.status === 403 && Number.isFinite(remaining) && remaining > 0) { + + // Distinguish "rate-limited" 403 from "forbidden" 403. A primary-limit 403 + // has remaining === 0; a secondary-limit 403 (too many concurrent requests, + // too many points per minute) carries a retry-after header while remaining + // can still be > 0. Only a 403 with budget left and no retry-after is a real + // auth/permission answer. + if ( + res.status === 403 && + retryAfterHeader === null && + Number.isFinite(remaining) && + remaining > 0 + ) { return res; } - const retryAfter = Number(res.headers.get("retry-after") ?? ""); const reset = Number(res.headers.get("x-ratelimit-reset") ?? "0") * 1000; + // GitHub's documented order: honour retry-after; else, if remaining is 0, + // wait for the reset; else (secondary limit without a retry-after) wait a + // minute before retrying. let waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 - : Math.max(reset - Date.now(), 1000); + : remaining === 0 + ? Math.max(reset - Date.now(), 1000) + : 60_000; waitMs = Math.min(waitMs, maxWaitMs - totalWait); if (waitMs <= 0 || attempt === maxAttempts) return res;