Skip to content
Open
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
93 changes: 50 additions & 43 deletions src/app/api/metrics/contributions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -149,7 +155,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) {
Expand All @@ -158,69 +163,71 @@ 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 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 remainingPages: number[] = [];
for (let p = 2; p <= totalNeededPages; p++) {
remainingPages.push(p);
}

if (allItems.length >= 1000 || allItems.length >= totalCount) {
break;
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);
}
}

page += 1;
}

const commitsByDay: Record<string, number> = {};
Expand Down
Loading