Skip to content

Commit d99c9f9

Browse files
committed
fix: cap concurrent GitHub Search page fetches to avoid secondary rate limit
GitHub recommends against firing concurrent requests for a single user token, since Search has a low secondary rate limit. The previous implementation fired up to 9 pages via a single Promise.all, risking a secondary-rate-limit block. Batch remaining pages through a bounded pool of 3 concurrent requests instead, keeping the timeout mitigation while staying within GitHub's guidance. Rate-limited pages continue to contribute no items, falling back to whatever partial results were already fetched, as intended.
1 parent 69be45d commit d99c9f9

1 file changed

Lines changed: 23 additions & 6 deletions

File tree

  • src/app/api/metrics/contributions

src/app/api/metrics/contributions/route.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ import { logError } from "@/lib/error-handler";
3838
// ──────────────────────────────────────────────────────────────────────────────
3939
export const dynamic = "force-dynamic";
4040

41+
// GitHub's guidance is to make requests for a single user serially rather
42+
// than concurrently, since Search has a low secondary rate limit. We fetch
43+
// remaining pages in small batches instead of fully in parallel, trading a
44+
// bit of latency for a much lower chance of tripping that limit.
45+
const PAGE_FETCH_CONCURRENCY = 3;
46+
4147
interface TimeBlocks {
4248
morning: number;
4349
afternoon: number;
@@ -199,17 +205,28 @@ async function fetchContributionsForAccount(
199205
throw new Error(`GitHub API error: ${firstPage.status}`);
200206
}
201207

202-
// Fetch remaining pages in parallel to prevent Serverless timeouts
208+
// Fetch remaining pages with a small bounded concurrency pool to
209+
// reduce latency vs. sequential fetching, without fanning out enough
210+
// requests at once to trip GitHub Search's secondary rate limit.
211+
// GitHub recommends against firing concurrent requests for a single
212+
// user token; capping to a small pool keeps us within that guidance
213+
// while still avoiding serverless timeouts on highly active users.
203214
if (!firstPage.rateLimited && firstPage.items.length === 100 && totalCount > 100) {
204215
const totalNeededPages = Math.min(10, Math.ceil(totalCount / 100));
205-
const promises: ReturnType<typeof fetchPage>[] = [];
216+
const remainingPages: number[] = [];
206217
for (let p = 2; p <= totalNeededPages; p++) {
207-
promises.push(fetchPage(p));
218+
remainingPages.push(p);
208219
}
209220

210-
const results = await Promise.all(promises);
211-
for (const res of results) {
212-
allItems = allItems.concat(res.items);
221+
for (let i = 0; i < remainingPages.length; i += PAGE_FETCH_CONCURRENCY) {
222+
const batch = remainingPages.slice(i, i + PAGE_FETCH_CONCURRENCY);
223+
const batchResults = await Promise.all(batch.map((p) => fetchPage(p)));
224+
for (const res of batchResults) {
225+
// Rate-limited pages intentionally contribute no items; the
226+
// response falls back to whatever pages were fetched before
227+
// the limit was hit, rather than failing the whole request.
228+
allItems = allItems.concat(res.items);
229+
}
213230
}
214231
}
215232

0 commit comments

Comments
 (0)