From d7b192c3ed260bf505f2d13bacdb779c160ce4b7 Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 6 May 2026 13:07:54 -1000 Subject: [PATCH] perf: share fetchContributionData across endpoints to avoid GitHub rate limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pagination fix (24d6cd5) raised the per-fetch cost from 3 to up to 12 search API calls. With 4 widget endpoints fetching independently on cold start, a burst can hit ~48 search calls and trip GitHub's 30/min limit. Observed in production immediately after the fix shipped. This adds a module-level promise cache keyed by username so concurrent endpoint hits share a single in-flight fetch. Each cold-start instance now makes one fetchContributionData call per username regardless of how many endpoints are hit. The ?cache=no query param still forces a fresh fetch. Errors and rejections are not cached — next call retries. --- lib/contribution-cache.test.ts | 89 ++++++++++++++++++++++++++++++++++ lib/contribution-cache.ts | 41 ++++++++++++++++ lib/endpoint-handler.test.ts | 2 + lib/endpoint-handler.ts | 4 +- 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 lib/contribution-cache.test.ts create mode 100644 lib/contribution-cache.ts diff --git a/lib/contribution-cache.test.ts b/lib/contribution-cache.test.ts new file mode 100644 index 0000000..191fb5f --- /dev/null +++ b/lib/contribution-cache.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('./github-data.js', () => ({ + fetchContributionData: fetchMock, +})); + +import { getContributionData, _resetForTest } from './contribution-cache.js'; + +const okData = { + merged: 5, + open: 0, + closedUnmerged: 0, + mergeRate: 100, + repoCount: 1, + recentPRs: [], + cappedMerged: false, + cappedClosedUnmerged: false, + dailyActivity: {}, + streak: 0, + topRepos: [], +}; + +describe('getContributionData', () => { + beforeEach(() => { + fetchMock.mockReset(); + _resetForTest(); + }); + + it('deduplicates concurrent calls for the same username', async () => { + fetchMock.mockResolvedValue(okData); + + const [a, b, c, d] = await Promise.all([ + getContributionData('alice', 'tok'), + getContributionData('alice', 'tok'), + getContributionData('alice', 'tok'), + getContributionData('alice', 'tok'), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + expect(b).toBe(c); + expect(c).toBe(d); + }); + + it('returns separate fetches for different usernames', async () => { + fetchMock.mockResolvedValue(okData); + + await Promise.all([ + getContributionData('alice', 'tok'), + getContributionData('bob', 'tok'), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not cache error results — next call retries', async () => { + fetchMock.mockResolvedValueOnce({ error: 'rate_limited' as const }); + fetchMock.mockResolvedValueOnce(okData); + + const first = await getContributionData('alice', 'tok'); + expect((first as { error?: string }).error).toBe('rate_limited'); + + const second = await getContributionData('alice', 'tok'); + expect(second).toEqual(okData); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not cache rejected promises — next call retries', async () => { + fetchMock.mockRejectedValueOnce(new Error('boom')); + fetchMock.mockResolvedValueOnce(okData); + + await expect(getContributionData('alice', 'tok')).rejects.toThrow('boom'); + const second = await getContributionData('alice', 'tok'); + expect(second).toEqual(okData); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('serves the same successful result on subsequent calls within TTL', async () => { + fetchMock.mockResolvedValue(okData); + + const a = await getContributionData('alice', 'tok'); + const b = await getContributionData('alice', 'tok'); + + expect(a).toBe(b); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/contribution-cache.ts b/lib/contribution-cache.ts new file mode 100644 index 0000000..f9c1ca3 --- /dev/null +++ b/lib/contribution-cache.ts @@ -0,0 +1,41 @@ +import { fetchContributionData, type ContributionResult } from './github-data.js'; + +const TTL_MS = 60 * 60 * 1000; + +interface Entry { + promise: Promise; + ts: number; +} + +const cache = new Map(); + +export function getContributionData( + username: string, + token: string, + options: { force?: boolean } = {}, +): Promise { + if (!options.force) { + const existing = cache.get(username); + if (existing && Date.now() - existing.ts < TTL_MS) { + return existing.promise; + } + } + + const promise = fetchContributionData(username, token); + const entry: Entry = { promise, ts: Date.now() }; + cache.set(username, entry); + + promise + .then((result) => { + if (result.error && cache.get(username) === entry) cache.delete(username); + }) + .catch(() => { + if (cache.get(username) === entry) cache.delete(username); + }); + + return promise; +} + +export function _resetForTest(): void { + cache.clear(); +} diff --git a/lib/endpoint-handler.test.ts b/lib/endpoint-handler.test.ts index acfa3d9..aa31bf7 100644 --- a/lib/endpoint-handler.test.ts +++ b/lib/endpoint-handler.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createWidgetHandler } from './endpoint-handler.js'; import type { ContributionData } from './github-data.js'; +import { _resetForTest as resetContributionCache } from './contribution-cache.js'; // Hoist mock references so they're available inside vi.mock factory const { fetchMock } = vi.hoisted(() => { @@ -77,6 +78,7 @@ describe('createWidgetHandler', () => { beforeEach(() => { vi.clearAllMocks(); + resetContributionCache(); originalToken = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'fake-token'; BASE_CONFIG.render.mockImplementation(() => 'widget'); diff --git a/lib/endpoint-handler.ts b/lib/endpoint-handler.ts index e581fd9..e938a44 100644 --- a/lib/endpoint-handler.ts +++ b/lib/endpoint-handler.ts @@ -1,10 +1,10 @@ import { - fetchContributionData, isValidUsername, type ContributionData, type ContributionResult, type ThemeMode, } from './github-data.js'; +import { getContributionData } from './contribution-cache.js'; import { escapeXml, theme as getTheme } from './svg-utils.js'; import type { VercelRequest, VercelResponse } from './vercel-types.js'; @@ -89,7 +89,7 @@ export function createWidgetHandler(config: WidgetHandlerConfig) { // Wrap fetch + optional transform as a single computation const computation = (async (): Promise => { - const result = await fetchContributionData(username, process.env.GITHUB_TOKEN!); + const result = await getContributionData(username, process.env.GITHUB_TOKEN!, { force: noCache }); if (result.error) return result; if (transform) { try {