Skip to content

Commit a5855f7

Browse files
committed
Fix rate-limit retry and error management for code search and team fetching
- Add formatRetryWait() to produce human-readable wait durations - Add isRateLimitExceeded() to detect GitHub primary rate-limit 403s (x-ratelimit-remaining: 0) in addition to standard 429/503 - Add getRetryDelayMs() consolidating x-ratelimit-reset > Retry-After > backoff - fetchWithRetry(): check threshold on base delay (before jitter) to avoid false-positive long-wait errors; throw descriptive error when wait > 10 s - Add throwApiError() helper in api.ts: detects rate-limit by body message when headers are absent, produces clean error with wait time - Team-repos loop: throw cleanly on rate-limit 403 instead of logging warning - main() in entry point: catch any Error and print message only (no stack trace) Fixes #22
1 parent 62fc767 commit a5855f7

4 files changed

Lines changed: 253 additions & 40 deletions

File tree

github-code-search.ts

100755100644
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ async function main(): Promise<void> {
245245
writeFileSync(2, queryCmd.helpInformation() + "\n");
246246
process.exit(1);
247247
}
248+
// Any other known Error (e.g. rate-limit exceeded) → print a clean message
249+
// to stderr without a stack trace, then exit 1.
250+
if (e instanceof Error) {
251+
writeFileSync(2, `error: ${e.message}\n`);
252+
process.exit(1);
253+
}
248254
throw e;
249255
}
250256
}

src/api-utils.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2-
import { fetchWithRetry, paginatedFetch } from "./api-utils.ts";
2+
import { fetchWithRetry, formatRetryWait, paginatedFetch } from "./api-utils.ts";
33

44
const originalFetch = globalThis.fetch;
55
const originalSetTimeout = globalThis.setTimeout;
@@ -130,6 +130,108 @@ describe("fetchWithRetry", () => {
130130
});
131131
});
132132

133+
// ─── formatRetryWait ──────────────────────────────────────────────────────────
134+
135+
describe("formatRetryWait", () => {
136+
it("formats seconds when duration < 60 s", () => {
137+
expect(formatRetryWait(5_000)).toBe("5 seconds");
138+
});
139+
140+
it("uses singular 'second' for exactly 1 s", () => {
141+
expect(formatRetryWait(1_000)).toBe("1 second");
142+
});
143+
144+
it("rounds up sub-second remainders", () => {
145+
expect(formatRetryWait(1_500)).toBe("2 seconds");
146+
});
147+
148+
it("formats whole minutes without a seconds clause", () => {
149+
expect(formatRetryWait(120_000)).toBe("2 minutes");
150+
});
151+
152+
it("uses singular 'minute' for exactly 1 min", () => {
153+
expect(formatRetryWait(60_000)).toBe("1 minute");
154+
});
155+
156+
it("formats minutes and seconds when there is a remainder", () => {
157+
expect(formatRetryWait(90_000)).toBe("1 minute and 30 seconds");
158+
});
159+
160+
it("formats large values correctly", () => {
161+
expect(formatRetryWait(3_600_000)).toBe("60 minutes");
162+
});
163+
});
164+
165+
// ─── fetchWithRetry – rate-limit (403) handling ───────────────────────────────
166+
167+
describe("fetchWithRetry – 403 rate-limit handling", () => {
168+
it("retries on 403 rate-limit when wait is within the auto-retry threshold", async () => {
169+
let calls = 0;
170+
// reset = now + 1 second → well within the 10 s threshold
171+
const resetTimestamp = Math.ceil((Date.now() + 1_000) / 1_000);
172+
globalThis.fetch = (async () => {
173+
calls++;
174+
if (calls === 1) {
175+
return new Response("rate limited", {
176+
status: 403,
177+
headers: {
178+
"x-ratelimit-remaining": "0",
179+
"x-ratelimit-reset": String(resetTimestamp),
180+
},
181+
});
182+
}
183+
return new Response("ok", { status: 200 });
184+
}) as typeof fetch;
185+
186+
const res = await fetchWithRetry("https://example.com", {}, 3);
187+
expect(res.status).toBe(200);
188+
expect(calls).toBe(2);
189+
});
190+
191+
it("throws a human-readable error when the rate-limit reset is far in the future", async () => {
192+
// reset = now + 5 minutes → exceeds the 10 s auto-retry threshold
193+
const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000);
194+
globalThis.fetch = (async () => {
195+
return new Response("rate limited", {
196+
status: 403,
197+
headers: {
198+
"x-ratelimit-remaining": "0",
199+
"x-ratelimit-reset": String(resetTimestamp),
200+
},
201+
});
202+
}) as typeof fetch;
203+
204+
await expect(fetchWithRetry("https://example.com", {}, 3)).rejects.toThrow(
205+
/GitHub API rate limit exceeded\. Please retry in \d+ minute/,
206+
);
207+
});
208+
209+
it("does NOT treat a plain 403 (non-rate-limit) as retryable", async () => {
210+
let calls = 0;
211+
globalThis.fetch = (async () => {
212+
calls++;
213+
return new Response("forbidden", { status: 403 });
214+
}) as typeof fetch;
215+
216+
const res = await fetchWithRetry("https://example.com", {}, 3);
217+
expect(res.status).toBe(403);
218+
expect(calls).toBe(1);
219+
});
220+
221+
it("throws with a human-readable error when Retry-After header exceeds threshold", async () => {
222+
globalThis.fetch = (async () => {
223+
return new Response("rate limited", {
224+
status: 429,
225+
headers: { "Retry-After": "300" }, // 5 minutes
226+
});
227+
}) as typeof fetch;
228+
229+
await expect(fetchWithRetry("https://example.com", {}, 3)).rejects.toThrow(
230+
/GitHub API rate limit exceeded\. Please retry in \d+ minute/,
231+
);
232+
});
233+
});
234+
133235
// ─── paginatedFetch ───────────────────────────────────────────────────────────
134236

135237
describe("paginatedFetch", () => {

src/api-utils.ts

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,74 @@
66
const RETRYABLE_STATUSES = new Set([429, 503]);
77
const BASE_RETRY_DELAY_MS = 1_000;
88
const MAX_RETRY_DELAY_MS = 60_000;
9+
// Above this threshold the user is told to wait manually rather than blocking.
10+
const MAX_AUTO_RETRY_WAIT_MS = 10_000; // 10 seconds
911

1012
/**
11-
* Performs a `fetch` with automatic retry on 429 (rate-limited) and 503
12-
* (server unavailable), using exponential backoff with optional `Retry-After`
13+
* Format a millisecond duration as a human-readable "retry in …" string.
14+
* Values ≥ 60 s are expressed in minutes (and seconds when non-zero).
15+
*
16+
* @example formatRetryWait(90_000) → "1 minute and 30 seconds"
17+
* @example formatRetryWait(3_600_000) → "60 minutes"
18+
* @example formatRetryWait(5_000) → "5 seconds"
19+
*/
20+
export function formatRetryWait(ms: number): string {
21+
const totalSeconds = Math.ceil(ms / 1_000);
22+
if (totalSeconds >= 60) {
23+
const mins = Math.floor(totalSeconds / 60);
24+
const secs = totalSeconds % 60;
25+
const minStr = `${mins} minute${mins !== 1 ? "s" : ""}`;
26+
if (secs === 0) return minStr;
27+
return `${minStr} and ${secs} second${secs !== 1 ? "s" : ""}`;
28+
}
29+
return `${totalSeconds} second${totalSeconds !== 1 ? "s" : ""}`;
30+
}
31+
32+
/**
33+
* Returns true when the response is a GitHub primary rate-limit 403
34+
* (x-ratelimit-remaining is "0").
35+
*/
36+
function isRateLimitExceeded(res: Response): boolean {
37+
return res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0";
38+
}
39+
40+
/**
41+
* Compute the delay in milliseconds before the next retry attempt.
42+
* Prefers x-ratelimit-reset (Unix timestamp) > Retry-After header >
43+
* exponential back-off.
44+
*/
45+
function getRetryDelayMs(res: Response, attempt: number): number {
46+
// x-ratelimit-reset: Unix timestamp (seconds) when the quota refills
47+
const resetHeader = res.headers.get("x-ratelimit-reset");
48+
if (resetHeader !== null) {
49+
const resetTime = parseInt(resetHeader, 10);
50+
if (Number.isFinite(resetTime)) {
51+
return Math.max(0, resetTime * 1_000 - Date.now());
52+
}
53+
}
54+
// Retry-After: seconds to wait (used by 429 / secondary rate limits)
55+
const retryAfterHeader = res.headers.get("Retry-After");
56+
if (retryAfterHeader !== null) {
57+
const seconds = parseInt(retryAfterHeader, 10);
58+
if (Number.isFinite(seconds) && seconds > 0) {
59+
return seconds * 1_000;
60+
}
61+
}
62+
return Math.min(BASE_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
63+
}
64+
65+
/**
66+
* Performs a `fetch` with automatic retry on 429 (rate-limited), 503
67+
* (server unavailable) and 403 primary rate-limit responses, using
68+
* exponential backoff with optional `Retry-After` / `x-ratelimit-reset`
1369
* header support.
1470
*
1571
* Non-retryable responses (including successful ones) are returned immediately.
1672
* After `maxRetries` exhausted the last response is returned — callers must
1773
* still check `res.ok`.
74+
*
75+
* When the computed wait exceeds MAX_AUTO_RETRY_WAIT_MS the function throws a
76+
* descriptive error so the user isn't silently blocked for minutes.
1877
*/
1978
export async function fetchWithRetry(
2079
url: string,
@@ -24,22 +83,28 @@ export async function fetchWithRetry(
2483
let attempt = 0;
2584
while (true) {
2685
const res = await fetch(url, options);
27-
if (!RETRYABLE_STATUSES.has(res.status) || attempt >= maxRetries) {
86+
87+
// Fix: handle GitHub primary rate-limit (403 + x-ratelimit-remaining: 0)
88+
// in addition to the standard 429/503 retryable statuses — see issue #22
89+
const retryable = RETRYABLE_STATUSES.has(res.status) || isRateLimitExceeded(res);
90+
if (!retryable || attempt >= maxRetries) {
2891
return res;
2992
}
30-
const retryAfterHeader = res.headers.get("Retry-After");
31-
let delayMs: number;
32-
if (retryAfterHeader !== null) {
33-
const seconds = parseInt(retryAfterHeader, 10);
34-
delayMs =
35-
Number.isFinite(seconds) && seconds > 0
36-
? seconds * 1_000
37-
: Math.min(BASE_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
38-
} else {
39-
delayMs = Math.min(BASE_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
93+
94+
// Compute the base delay (no jitter yet) so the threshold check and the
95+
// error message both reflect the real wait time reported by the API.
96+
const baseDelayMs = getRetryDelayMs(res, attempt);
97+
98+
if (baseDelayMs > MAX_AUTO_RETRY_WAIT_MS) {
99+
// Cancel the response body before throwing to allow connection reuse
100+
await res.body?.cancel();
101+
throw new Error(
102+
`GitHub API rate limit exceeded. Please retry in ${formatRetryWait(baseDelayMs)}.`,
103+
);
40104
}
105+
41106
// Add ±10 % jitter to avoid thundering-herd on concurrent retries
42-
delayMs = delayMs * (0.9 + Math.random() * 0.2);
107+
const delayMs = baseDelayMs * (0.9 + Math.random() * 0.2);
43108
// Cancel the response body to allow the connection to be reused
44109
await res.body?.cancel();
45110
await new Promise((r) => setTimeout(r, delayMs));

src/api.ts

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pc from "picocolors";
22
import type { CodeMatch } from "./types.ts";
3-
import { fetchWithRetry, paginatedFetch } from "./api-utils.ts";
3+
import { fetchWithRetry, formatRetryWait, paginatedFetch } from "./api-utils.ts";
44
import { getCacheKey, readCache, writeCache } from "./cache.ts";
55

66
// ─── Raw GitHub API types (internal) ─────────────────────────────────────────
@@ -38,6 +38,40 @@ interface RawRepo {
3838

3939
// ─── API client ───────────────────────────────────────────────────────────────
4040

41+
/**
42+
* Read a GitHub API error response and throw a human-readable Error.
43+
* When the response body signals a rate-limit condition the message includes
44+
* the wait time derived from the x-ratelimit-reset header.
45+
*/
46+
async function throwApiError(res: Response, context?: string): Promise<never> {
47+
let apiMsg = "";
48+
let resetHeader: string | null = null;
49+
try {
50+
const body = (await res.json()) as { message?: string };
51+
apiMsg = body.message ?? "";
52+
resetHeader = res.headers.get("x-ratelimit-reset");
53+
} catch {
54+
// Ignore JSON parse errors; fall through to generic message
55+
}
56+
57+
// Fix: detect rate-limit by body message when headers are absent — see issue #22
58+
if (
59+
res.status === 403 &&
60+
(res.headers.get("x-ratelimit-remaining") === "0" ||
61+
apiMsg.toLowerCase().includes("rate limit"))
62+
) {
63+
let wait = "";
64+
if (resetHeader !== null) {
65+
const resetMs = parseInt(resetHeader, 10) * 1_000 - Date.now();
66+
if (resetMs > 0) wait = ` Please retry in ${formatRetryWait(resetMs)}.`;
67+
}
68+
throw new Error(`GitHub API rate limit exceeded.${wait}`);
69+
}
70+
71+
const ctx = context ? ` (${context})` : "";
72+
throw new Error(`GitHub API error ${res.status}${ctx}: ${apiMsg || "(no message)"}`);
73+
}
74+
4175
/**
4276
* Build common GitHub API request headers.
4377
*/
@@ -104,10 +138,7 @@ export async function searchCode(
104138
const res = await fetchWithRetry(`https://api.github.com/search/code?${params}`, {
105139
headers: githubHeaders(token),
106140
});
107-
if (!res.ok) {
108-
const body = await res.text();
109-
throw new Error(`GitHub API error ${res.status}: ${body}`);
110-
}
141+
if (!res.ok) await throwApiError(res);
111142
const data = (await res.json()) as SearchCodeResponse;
112143
return { items: data.items ?? [], total: data.total_count ?? 0 };
113144
}
@@ -222,10 +253,7 @@ export async function fetchRepoTeams(
222253
const res = await fetchWithRetry(`https://api.github.com/orgs/${org}/teams?${params}`, {
223254
headers: githubHeaders(token, "application/vnd.github+json"),
224255
});
225-
if (!res.ok) {
226-
const body = await res.text();
227-
throw new Error(`GitHub API error ${res.status} (list teams): ${body}`);
228-
}
256+
if (!res.ok) await throwApiError(res, "list teams");
229257
const teams = (await res.json()) as RawTeam[];
230258
for (const t of teams) {
231259
if (lowerPrefixes.some((p) => t.slug.toLowerCase().startsWith(p))) {
@@ -260,24 +288,36 @@ export async function fetchRepoTeams(
260288
);
261289
if (!res.ok) {
262290
// 404 is expected for nested/secret teams — skip silently.
263-
// Other errors are unexpected: read the body, log a warning, and stop.
264-
if (res.status !== 404) {
265-
let bodyText = "";
266-
try {
267-
bodyText = (await res.text()).trim();
268-
} catch {
269-
// Ignore errors while reading the body; we still want to log something.
270-
}
271-
if (bodyText.length > 200) {
272-
bodyText = bodyText.slice(0, 200) + "…";
291+
if (res.status === 404) {
292+
break;
293+
}
294+
// Rate-limit 403: throw a clean error like every other rate-limit hit.
295+
const bodyJson = await res
296+
.json()
297+
.catch(() => ({}) as { message?: string });
298+
const apiMsg: string = (bodyJson as { message?: string }).message ?? "";
299+
if (
300+
res.status === 403 &&
301+
(res.headers.get("x-ratelimit-remaining") === "0" ||
302+
apiMsg.toLowerCase().includes("rate limit"))
303+
) {
304+
const resetHeader = res.headers.get("x-ratelimit-reset");
305+
let wait = "";
306+
if (resetHeader !== null) {
307+
const resetMs = parseInt(resetHeader, 10) * 1_000 - Date.now();
308+
if (resetMs > 0) wait = ` Please retry in ${formatRetryWait(resetMs)}.`;
273309
}
274-
const message = bodyText ? `; body: ${bodyText}` : "";
275-
process.stderr.write(
276-
pc.dim(
277-
`Warning: could not fetch repos for team "${slug}" (HTTP ${res.status}${message})\n`,
278-
),
279-
);
310+
throw new Error(`GitHub API rate limit exceeded.${wait}`);
280311
}
312+
// Other errors are unexpected: log a warning and skip this team.
313+
let bodyText = apiMsg || JSON.stringify(bodyJson);
314+
if (bodyText.length > 200) bodyText = bodyText.slice(0, 200) + "…";
315+
const message = bodyText ? `; body: ${bodyText}` : "";
316+
process.stderr.write(
317+
pc.dim(
318+
`Warning: could not fetch repos for team "${slug}" (HTTP ${res.status}${message})\n`,
319+
),
320+
);
281321
break;
282322
}
283323
const repos = (await res.json()) as RawRepo[];

0 commit comments

Comments
 (0)