From 186b867ab1f98ea81e9826da99fc7b9627124a1e Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Wed, 8 Jul 2026 22:22:25 +0530 Subject: [PATCH 1/3] fix(performance): fetch GitHub commits concurrently to prevent serverless timeouts --- src/app/api/metrics/contributions/route.ts | 76 ++++++++++------------ 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 225440241..4adcef1c8 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -149,7 +149,6 @@ async function fetchContributionsForAccount( let allItems: GitHubCommitSearchItem[] = []; const commitItems: CommitItem[] = []; let totalCount = 0; - let page = 1; let q = `author:${githubLogin} author-date:>=${sinceStr}${repoFilter}`; if (orgName) { @@ -158,69 +157,60 @@ async function fetchContributionsForAccount( q += excludedOrgs.map((org) => ` -org:${org}`).join(""); } - // Note: this may issue up to 10 sequential GitHub Search API calls (max 1000 results). - // Authenticated GitHub Search rate limits are low (~30 req/min). We handle 429/403 - // responses gracefully by returning partial results rather than failing the endpoint. - while (page <= 10) { + const fetchPage = async (pageNumber: number) => { const searchUrl = new URL(`${GITHUB_API}/search/commits`); searchUrl.searchParams.set("q", q); searchUrl.searchParams.set("per_page", "100"); - searchUrl.searchParams.set("page", String(page)); + searchUrl.searchParams.set("page", String(pageNumber)); searchUrl.searchParams.set("sort", "author-date"); searchUrl.searchParams.set("order", "desc"); // The Authorization header upgrades the rate limit from 60 req/hr // (unauthenticated, shared per IP) to 5,000 req/hr (per user). - // Without it, multiple users on the same server IP would exhaust - // the shared quota almost immediately. - // Authorization header raises the rate limit from 60 req/hr (unauthenticated, - // shared per IP) to 5,000 req/hr per user. Without it, shared server IPs - // would exhaust the unauthenticated quota almost immediately. - const searchRes = await fetch( - searchUrl.toString(), - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); + const searchRes = await fetch(searchUrl.toString(), { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + }); if (!searchRes.ok) { - throwIfGitHubRateLimited(searchRes); - - if (searchRes.status === 429 || searchRes.status === 403) { - if (allItems.length === 0) { - throw new Error(`GitHub API error: ${searchRes.status}`); - } - - break; - } - - throw new Error(`GitHub API error: ${searchRes.status}`); -} + throwIfGitHubRateLimited(searchRes); + if (searchRes.status === 429 || searchRes.status === 403) { + return { items: [], total_count: 0, rateLimited: true, status: searchRes.status }; + } + throw new Error(`GitHub API error: ${searchRes.status}`); + } const data = (await searchRes.json()) as { total_count: number; items: GitHubCommitSearchItem[]; }; + return { items: data.items, total_count: data.total_count, rateLimited: false, status: 200 }; + }; - if (page === 1) { - totalCount = data.total_count; - } + // Fetch first page sequentially to get total count + const firstPage = await fetchPage(1); + totalCount = firstPage.total_count; + allItems = allItems.concat(firstPage.items); - allItems = allItems.concat(data.items); + if (firstPage.rateLimited && allItems.length === 0) { + throw new Error(`GitHub API error: ${firstPage.status}`); + } - if (data.items.length < 100) { - break; + // Fetch remaining pages in parallel to prevent Serverless timeouts + if (!firstPage.rateLimited && firstPage.items.length === 100 && totalCount > 100) { + const totalNeededPages = Math.min(10, Math.ceil(totalCount / 100)); + const promises = []; + for (let p = 2; p <= totalNeededPages; p++) { + promises.push(fetchPage(p)); } - if (allItems.length >= 1000 || allItems.length >= totalCount) { - break; + const results = await Promise.all(promises); + for (const res of results) { + allItems = allItems.concat(res.items); } - - page += 1; } const commitsByDay: Record = {}; From 69be45da2aec932cb4edfbe181b596d4135df626 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Wed, 8 Jul 2026 22:25:49 +0530 Subject: [PATCH 2/3] fix(types): add explicit type for promises array to resolve TS2345 --- src/app/api/metrics/contributions/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 4adcef1c8..3f1235827 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -202,7 +202,7 @@ async function fetchContributionsForAccount( // Fetch remaining pages in parallel to prevent Serverless timeouts if (!firstPage.rateLimited && firstPage.items.length === 100 && totalCount > 100) { const totalNeededPages = Math.min(10, Math.ceil(totalCount / 100)); - const promises = []; + const promises: ReturnType[] = []; for (let p = 2; p <= totalNeededPages; p++) { promises.push(fetchPage(p)); } From d99c9f95ec042c93cfbe99fe255722c488ec6a3d Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Mon, 20 Jul 2026 20:06:53 +0530 Subject: [PATCH 3/3] 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. --- src/app/api/metrics/contributions/route.ts | 29 +++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 3f1235827..e1057297c 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -38,6 +38,12 @@ import { logError } from "@/lib/error-handler"; // ────────────────────────────────────────────────────────────────────────────── export const dynamic = "force-dynamic"; +// GitHub's guidance is to make requests for a single user serially rather +// than concurrently, since Search has a low secondary rate limit. We fetch +// remaining pages in small batches instead of fully in parallel, trading a +// bit of latency for a much lower chance of tripping that limit. +const PAGE_FETCH_CONCURRENCY = 3; + interface TimeBlocks { morning: number; afternoon: number; @@ -199,17 +205,28 @@ async function fetchContributionsForAccount( throw new Error(`GitHub API error: ${firstPage.status}`); } - // Fetch remaining pages in parallel to prevent Serverless timeouts + // Fetch remaining pages with a small bounded concurrency pool to + // reduce latency vs. sequential fetching, without fanning out enough + // requests at once to trip GitHub Search's secondary rate limit. + // GitHub recommends against firing concurrent requests for a single + // user token; capping to a small pool keeps us within that guidance + // while still avoiding serverless timeouts on highly active users. if (!firstPage.rateLimited && firstPage.items.length === 100 && totalCount > 100) { const totalNeededPages = Math.min(10, Math.ceil(totalCount / 100)); - const promises: ReturnType[] = []; + const remainingPages: number[] = []; for (let p = 2; p <= totalNeededPages; p++) { - promises.push(fetchPage(p)); + remainingPages.push(p); } - const results = await Promise.all(promises); - for (const res of results) { - allItems = allItems.concat(res.items); + for (let i = 0; i < remainingPages.length; i += PAGE_FETCH_CONCURRENCY) { + const batch = remainingPages.slice(i, i + PAGE_FETCH_CONCURRENCY); + const batchResults = await Promise.all(batch.map((p) => fetchPage(p))); + for (const res of batchResults) { + // Rate-limited pages intentionally contribute no items; the + // response falls back to whatever pages were fetched before + // the limit was hit, rather than failing the whole request. + allItems = allItems.concat(res.items); + } } }