Engineering3 min read

How to scan 89 repositories without burning your rate limit

The naive version of a commit scanner fires hundreds of concurrent requests and exhausts a 5,000 per hour quota in minutes. The fix is mostly about not asking.

GitHub gives you 5,000 API requests per hour per installation. That sounds like a lot until you write a scanner naively.

Our first version did this:

const details = await Promise.all(
  commits.map(c => getCommitDetail(token, owner, repo, c.sha))
);

inside a loop that processed five repositories at a time. Forty commits per repository is ordinary for an active developer. Five repositories in flight, forty commits each, all fired at once, is two hundred concurrent requests. GitHub starts returning secondary rate limit errors well before the quota itself runs out.

Do not ask in the first place

The largest saving is not in how you make requests. It is in the requests you never make.

Every repository in GitHub's listing carries pushed_at. Before asking for commits in a date window, filter the repository list to those pushed at or after the window starts:

const candidates = reposWithActivitySince(repos, range.startUtc);

On a real account with 89 repositories, scanning a single day, this reduced 89 repositories to 10. Seventy nine requests avoided for the cost of a field already present in a response we needed anyway.

That is a bigger win than any amount of concurrency tuning, and it scales the right way: the more repositories someone has, the larger the proportion that are idle on any given day.

Cap the concurrency

Once you know which repositories to ask about, bound how many requests are in flight. Promise.all is not a scheduler, it is a starting gun.

export async function mapWithConcurrency<T, R>(
  items: readonly T[],
  concurrency: number,
  worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
  const limit = Math.max(1, Math.min(concurrency, items.length));
  const results = new Array<R>(items.length);
  let cursor = 0;

  async function runner(): Promise<void> {
    while (true) {
      const index = cursor++;
      if (index >= items.length) return;
      results[index] = await worker(items[index], index);
    }
  }

  await Promise.all(Array.from({ length: limit }, runner));
  return results;
}

Six workers pulling from a shared cursor. Order is preserved, and adding more items no longer adds load. On a real scan of six active repositories and 65 commits, total usage was 122 requests out of 8,450 remaining.

Stop before zero

A scanner that runs the quota to zero leaves the user unable to do anything else, including the interactive things they actually notice.

So there is a floor:

export const QUOTA_FLOOR = 100;

export function shouldStopForQuota(snapshot, floor = QUOTA_FLOOR) {
  return snapshot !== null && snapshot.remaining <= floor;
}

When remaining drops to 100, the scan stops and marks itself partial. The user sees "GitHub's API limit was close, so this scan stopped early" rather than a broken product and a mysteriously empty afternoon.

Back off properly

Retries need jitter. Exponential backoff without it synchronises every retrying client onto the same instant, which is how a rate limit becomes a thundering herd.

const exponential = Math.min(baseMs * 2 ** attempt, maxMs);
return Math.floor(random() * exponential);

Full jitter: a random point in the window rather than the end of it. And when GitHub sends retry-after, that wins, because it is the server telling you exactly what it wants.

Retry 5xx, rate limits and network errors. Never retry 401, 403 or 404. Retrying an auth failure burns quota to receive the same answer.

Distinguish your failures

The original code caught everything and returned an empty array:

try {
  data = await ghFetch(/* ... */);
} catch {
  return [];
}

An expired token and a quiet day produced identical output. The user saw "no activity found yesterday" when the truth was "your connection broke three days ago".

Every failure mode now has its own type and its own message:

Condition Shown to the user
401 Your GitHub connection has expired. Reconnect to continue.
Rate limited GitHub's API limit was reached. We will retry later.
403 We do not have permission to read that repository.
5xx GitHub is having problems right now.

One more distinction matters: an auth failure during a multi repository scan aborts the whole scan, because it is systemic. Any other per repository failure degrades that repository only and gets reported in a failures list. One unreachable repo should not cost you the other eight.

githubengineeringperformance

Keep reading