Skip to content

fetchUserRepos can make up to 10,000 sequential API calls β€” exhausts GitHub PAT rate limitΒ #911

Description

@Daksh7785

Description

🧩 Background
fetchUserRepos is called from getFullDashboardData to power the developer dashboard. It uses a while loop to paginate through GitHub's REST API for a user's repositories.

πŸ› What's Wrong
The current pagination loop is:

let PAGE = 1;
const MAX_PAGES = 100;
while (PAGE <= MAX_PAGES) {
const res = await fetchWithRetry(
${GITHUB_REST_URL}/users/${username}/repos?per_page=100&page=${PAGE}&sort=pushed,
{ headers: getHeaders(), cache: 'no-store' }
);
const repos = (await res.json()) as GitHubRepo[];
allRepos.push(...repos);
if (repos.length < 100) break;
PAGE++;
}

MAX_PAGES = 100 with per_page = 100 means up to 10,000 sequential REST API calls for a single user. There are real GitHub accounts with 500–1,000+ repositories. For those users:

Each iteration awaits the previous one β€” this is sequential blocking, not parallel. A 500-repo user triggers 5 back-to-back requests before the page loads.
GitHub's authenticated REST API rate limit is 5,000 requests/hour shared across all REST + GraphQL calls. A single dashboard load for a prolific user can consume hundreds of those credits.
The full allRepos[] array is cached in TTLCache β€” for a 1,000-repo user this is a large in-memory object that can't be garbage-collected until the TTL expires.

πŸ“ Files to Change

lib/github.ts β€” fetchUserRepos function

βœ… Proposed Fix
The developerScore formula only needs two things from repos: stargazers_count (for star count) and language (for language distribution). The top 100 repos sorted by recent push are sufficient for both. There is no need to paginate further.
Option A β€” Simplest (single page fetch):
typescriptexport async function fetchUserRepos(
username: string,
options: FetchOptions = {}
): Promise<GitHubRepo[]> {
if (!validateGitHubUsername(username)) {
throw new Error('Invalid GitHub username format');
}

const key = cacheKey('repos', username);
if (!options.bypassCache) {
const cached = reposCache.get(key);
if (cached) return cached;
}

// One page is sufficient β€” developerScore only needs stars + languages
const res = await fetchWithRetry(
${GITHUB_REST_URL}/users/${username}/repos?per_page=100&sort=pushed,
{ headers: getHeaders(), cache: 'no-store' }
);

if (!res.ok) throw new Error(GitHub REST API error: ${res.status});

const repos = (await res.json()) as GitHubRepo[];

if (!options.bypassCache) {
reposCache.set(key, repos, GITHUB_CACHE_TTL_MS);
}

return repos;
}
Option B β€” Capped pagination (if more repos are ever needed):
typescriptconst MAX_PAGES = 3; // 300 repos max β€” sufficient for score + language stats

πŸ§ͺ Tests to Add
typescript// lib/github.test.ts
it('fetches at most one page of repos', async () => {
let fetchCallCount = 0;
vi.spyOn(global, 'fetch').mockImplementation(async (url) => {
if (String(url).includes('/repos')) fetchCallCount++;
return {
ok: true,
json: async () => Array(100).fill({ stargazers_count: 1, language: 'TypeScript' }),
} as Response;
});

await fetchUserRepos('some-prolific-user');
expect(fetchCallCount).toBeLessThanOrEqual(1);
});

it('does not throw for users with 0 repos', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
await expect(fetchUserRepos('empty-user')).resolves.toEqual([]);
});

πŸ’¬ Commit Message
fix(github): cap repo pagination to prevent rate limit exhaustion

Steps to Reproduce

na

Expected Behavior

na

Screenshots / Logs

No response

GitHub Username (If applicable)

No response

Environment

Chrome

Metadata

Metadata

Assignees

Labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions