diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index d03ffc5f5..447580844 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -452,8 +452,15 @@ describe('GET /api/streak', () => { }); describe('cache-control header', () => { - it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => { + it('caches SVG responses with static max-age and stale-while-revalidate values', async () => { const response = await GET(makeRequest({ user: 'octocat' })); + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=300, stale-while-revalidate=3600' + ); + }); + + it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => { + const response = await GET(makeRequest({ user: 'octocat', format: 'png' })); expect(response.headers.get('Cache-Control')).toBe( 'public, max-age=60, s-maxage=3600, stale-while-revalidate=59' ); @@ -461,7 +468,7 @@ describe('GET /api/streak', () => { it('reflects a different time value when the clock changes', async () => { vi.mocked(getSecondsUntilUTCMidnight).mockReturnValue(7200); - const response = await GET(makeRequest({ user: 'octocat' })); + const response = await GET(makeRequest({ user: 'octocat', format: 'png' })); expect(response.headers.get('Cache-Control')).toBe( 'public, max-age=60, s-maxage=7200, stale-while-revalidate=59' ); @@ -1022,7 +1029,9 @@ describe('GET /api/streak', () => { }); it('uses getSecondsUntilMidnightInTimezone (not UTC) for the cache TTL when ?tz= is set', async () => { - const response = await GET(makeRequest({ user: 'octocat', tz: 'America/New_York' })); + const response = await GET( + makeRequest({ user: 'octocat', tz: 'America/New_York', format: 'png' }) + ); expect(response.headers.get('Cache-Control')).toBe( 'public, max-age=60, s-maxage=7200, stale-while-revalidate=59' @@ -1368,7 +1377,7 @@ describe('GET /api/streak', () => { describe('theme=random cache header', () => { it('returns no-cache header when ?theme=random is given', async () => { - const response = await GET(makeRequest({ user: 'octocat', theme: 'random' })); + const response = await GET(makeRequest({ user: 'octocat', theme: 'random', format: 'png' })); expect(response.headers.get('Cache-Control')).toMatch(/public, max-age=60, s-maxage=/); }); @@ -1584,13 +1593,13 @@ describe('GET /api/streak', () => { describe('stale-while-revalidate cache header', () => { it('contains stale-while-revalidate=59 for normal request', async () => { - const response = await GET(makeRequest({ user: 'octocat' })); + const response = await GET(makeRequest({ user: 'octocat', format: 'png' })); expect(response.headers.get('Cache-Control')).toContain('stale-while-revalidate=59'); }); it('does NOT contain stale-while-revalidate when ?refresh=true', async () => { - const response = await GET(makeRequest({ user: 'octocat', refresh: 'true' })); + const response = await GET(makeRequest({ user: 'octocat', refresh: 'true', format: 'png' })); expect(response.headers.get('Cache-Control')).not.toContain('stale-while-revalidate=59'); }); diff --git a/app/api/streak/route.timezone-boundaries.test.ts b/app/api/streak/route.timezone-boundaries.test.ts index d5f77ee1c..29af4a204 100644 --- a/app/api/streak/route.timezone-boundaries.test.ts +++ b/app/api/streak/route.timezone-boundaries.test.ts @@ -83,6 +83,7 @@ describe('ApiStreakRoute Timezone Normalization & Calendar Boundary Alignment', makeRequest({ user: 'octocat', tz: 'Asia/Kolkata', + format: 'png', }) ); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index b7a803e6e..4e5dc316b 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -717,11 +717,14 @@ export async function GET(request: Request) { const secondsToMidnight = tzParam ? getSecondsUntilMidnightInTimezone(timezone) : getSecondsUntilUTCMidnight(); + const isPngRoute = request.url.includes('/api/streak/png') || format === 'png'; const cacheControl = isRefreshRequested ? 'no-cache, no-store, must-revalidate' : isHistoricalYear ? 'public, max-age=31536000, s-maxage=31536000, immutable' - : `public, max-age=60, s-maxage=${secondsToMidnight}, stale-while-revalidate=59`; + : isPngRoute + ? `public, max-age=60, s-maxage=${secondsToMidnight}, stale-while-revalidate=59` + : 'public, max-age=300, stale-while-revalidate=3600'; const etag = crypto.createHash('sha256').update(svg).digest('hex'); const weakEtag = `W/"${etag}"`; diff --git a/app/api/streak/tests/dateRange.test.ts b/app/api/streak/tests/dateRange.test.ts index 2c6b74ea9..88c10ed5b 100644 --- a/app/api/streak/tests/dateRange.test.ts +++ b/app/api/streak/tests/dateRange.test.ts @@ -120,7 +120,7 @@ describe('GET /api/streak dateRange parameter', () => { expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); expect(res.headers.get('Cache-Control')).toBe( - 'public, max-age=60, s-maxage=3600, stale-while-revalidate=59' + 'public, max-age=300, stale-while-revalidate=3600' ); }); }); diff --git a/app/api/streak/tests/languages.test.ts b/app/api/streak/tests/languages.test.ts index 9b0128b7f..d54a2536d 100644 --- a/app/api/streak/tests/languages.test.ts +++ b/app/api/streak/tests/languages.test.ts @@ -98,7 +98,9 @@ describe('GET /api/streak — languages (lang) parameter', () => { it('sets Cache-Control and Content-Security-Policy headers for a valid lang parameter', async () => { const response = await GET(makeRequest({ user: 'octocat', lang: 'de' })); - expect(response.headers.get('Cache-Control')).toMatch(/public, max-age=60, s-maxage=\d+/); + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=300, stale-while-revalidate=3600' + ); const csp = response.headers.get('Content-Security-Policy'); expect(csp).toContain("default-src 'none'"); expect(csp).toContain("style-src 'unsafe-inline'"); diff --git a/app/api/streak/tests/refresh.test.ts b/app/api/streak/tests/refresh.test.ts index 78258095d..8eaf44516 100644 --- a/app/api/streak/tests/refresh.test.ts +++ b/app/api/streak/tests/refresh.test.ts @@ -105,7 +105,7 @@ describe('GET /api/streak - refresh parameter group', () => { expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe( - 'public, max-age=60, s-maxage=3600, stale-while-revalidate=59' + 'public, max-age=300, stale-while-revalidate=3600' ); expect(response.headers.get('X-Cache-Status')).toBe('HIT'); }); diff --git a/app/api/streak/tests/views.test.ts b/app/api/streak/tests/views.test.ts index d41932664..57bfb5e68 100644 --- a/app/api/streak/tests/views.test.ts +++ b/app/api/streak/tests/views.test.ts @@ -161,7 +161,7 @@ describe('GET /api/streak view parameter integration', () => { expect(response.status).toBe(200); expect(response.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); expect(response.headers.get('Cache-Control')).toBe( - 'public, max-age=60, s-maxage=3600, stale-while-revalidate=59' + 'public, max-age=300, stale-while-revalidate=3600' ); expect(response.headers.get('X-Cache-Status')).toBe('HIT'); }); diff --git a/app/api/streak/tests/weekday.test.ts b/app/api/streak/tests/weekday.test.ts index dbe396d33..c9b96088d 100644 --- a/app/api/streak/tests/weekday.test.ts +++ b/app/api/streak/tests/weekday.test.ts @@ -133,7 +133,7 @@ describe('GET /api/streak?view=weekday', () => { expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe( - 'public, max-age=60, s-maxage=3600, stale-while-revalidate=59' + 'public, max-age=300, stale-while-revalidate=3600' ); expect(response.headers.get('X-Cache-Status')).toBe('HIT'); }); diff --git a/lib/github.test.ts b/lib/github.test.ts index 96c8e9637..83d443cfc 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -454,7 +454,8 @@ describe('fetchGitHubContributions', () => { const key = cacheKey('contributions', 'fallback-user'); const cachedData = await contributionsCache.get(key); if (cachedData) { - cachedData.calendar.lastSyncedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + cachedData.data.calendar.lastSyncedAt = new Date(Date.now() - 70 * 60 * 1000).toISOString(); + cachedData.fetchedAt = Date.now() - 70 * 60 * 1000; await contributionsCache.set(key, cachedData, 7 * 24 * 60 * 60 * 1000); } diff --git a/lib/github.ts b/lib/github.ts index bc6ac634e..57fbd3184 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -612,7 +612,12 @@ type FetchOptions = { export const GITHUB_CACHE_TTL_MS = Number(process.env.GITHUB_CACHE_TTL_MS ?? String(5 * 60 * 1000)); -export const contributionsCache = new DistributedCache(1000); +export interface CachedContributions { + data: ExtendedContributionData; + fetchedAt: number; +} + +export const contributionsCache = new DistributedCache(1000); const profileCache = new DistributedCache(1000); const reposCache = new DistributedCache(500); const contributedReposCache = new DistributedCache(500); @@ -901,13 +906,6 @@ export async function fetchGitHubContributions( process.env.GITHUB_LONG_CACHE_TTL_MS ?? String(7 * 24 * 60 * 60 * 1000) ); - const shouldFetch = (cached: ExtendedContributionData) => { - const now = Date.now(); - return cached?.calendar.lastSyncedAt - ? now - new Date(cached.calendar.lastSyncedAt).getTime() > GITHUB_CACHE_TTL_MS - : true; - }; - const loadWithTimeout = async (): Promise => { const controller = new AbortController(); if (options.signal) { @@ -962,83 +960,50 @@ export async function fetchGitHubContributions( return pending; }; - if (options.signal) { - if (options.bypassCache || options.forceRefresh) { + const revalidateInBackground = (username: string, cacheKey: string) => { + const quota = quotaMonitor.getQuota(); + if (quota.remaining < 100) { + logger.warn('Background revalidation paused due to low GitHub API quota', { + username, + remaining: quota.remaining, + }); + return; + } + + (async () => { try { - return await loadWithTimeout(); + const fresh = await coalescedLoad(); + await contributionsCache.set( + cacheKey, + { data: fresh, fetchedAt: Date.now() }, + LONG_CACHE_TTL + ); } catch (err: unknown) { - if (shouldFallbackOnError(err)) { - const staleData = await contributionsCache.get(key); - if (staleData) { - logger.warn('GitHub API fetch failed, falling back to stale cache', { - component: 'GitHub API', - username, - error: err, - }); - return { - ...staleData, - isOfflineFallback: true, - }; - } - return getMockContributions(); - } - throw err; + logger.error('Background revalidation failed', { username, error: err }); } - } - const cached = await contributionsCache.get(key); - if (cached !== null && !shouldFetch(cached)) { - return cached; - } - try { - return await loadWithTimeout(); - } catch (err: unknown) { - if (shouldFallbackOnError(err)) { - const staleData = await contributionsCache.get(key); - if (staleData) { - logger.warn('GitHub API fetch failed, falling back to stale cache', { - component: 'GitHub API', - username, - error: err, - }); - return { - ...staleData, - isOfflineFallback: true, - }; - } - return getMockContributions(); - } - throw err; - } - } + })(); + }; - if (options.bypassCache || options.forceRefresh) { - try { - const result = await coalescedLoad(); - if (options.forceRefresh) { - await contributionsCache.set(key, result, LONG_CACHE_TTL); + if (!options.bypassCache && !options.forceRefresh) { + const cached = await contributionsCache.get(key); + if (cached) { + const age = Date.now() - cached.fetchedAt; + if (age < 60000) { + return cached.data; } - return result; - } catch (err: unknown) { - if (shouldFallbackOnError(err)) { - const staleData = await contributionsCache.get(key); - if (staleData) { - console.warn( - `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`, - err - ); - return { - ...staleData, - isOfflineFallback: true, - }; - } - return getMockContributions(); + if (age < 3600000) { + revalidateInBackground(username, key); + return cached.data; } - throw err; } } try { - return await contributionsCache.getOrSet(key, coalescedLoad, LONG_CACHE_TTL, shouldFetch); + const result = await coalescedLoad(); + if (!options.bypassCache || options.forceRefresh) { + await contributionsCache.set(key, { data: result, fetchedAt: Date.now() }, LONG_CACHE_TTL); + } + return result; } catch (err: unknown) { if (shouldFallbackOnError(err)) { const staleData = await contributionsCache.get(key); @@ -1049,7 +1014,7 @@ export async function fetchGitHubContributions( error: err, }); return { - ...staleData, + ...staleData.data, isOfflineFallback: true, }; }