Skip to content
Open
Show file tree
Hide file tree
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
6 changes: 5 additions & 1 deletion app/api/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

if (errMessage.includes('API limit reached') || errMessage.includes('status 403')) {
if (
errMessage.toLowerCase().includes('rate limit') ||
errMessage.includes('API limit reached') ||
errMessage.includes('status 403')
) {
return NextResponse.json(
{ error: 'GitHub API rate limit reached. Please configure GITHUB_TOKEN.' },
{ status: 403 }
Expand Down
11 changes: 11 additions & 0 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,17 @@ describe('GET /api/streak', () => {
expect(response.headers.get('Cache-Control')).toBe('public, s-maxage=60');
});

it('returns 429 with no-cache headers and rate limit SVG when rate limited', async () => {
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API Rate Limit Exceeded'));

const response = await GET(makeRequest({ user: 'octocat' }));

expect(response.status).toBe(429);
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
const body = await response.text();
expect(body).toContain('API RATE LIMIT');
});

it('returns a valid 500 SVG even when something non-Error is thrown', async () => {
// JavaScript lets you throw anything — strings, numbers, plain objects.
// The catch block checks instanceof Error; if that fails it falls back to "Unknown error".
Expand Down
14 changes: 14 additions & 0 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fetchGitHubContributions } from '../../../lib/github';
import { calculateStreak, calculateMonthlyStats } from '../../../lib/calculate';
import {
generateNotFoundSVG,
generateRateLimitSVG,
generateSVG,
generateMonthlySVG,
escapeXML,
Expand Down Expand Up @@ -177,6 +178,7 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
const isNotFound =
message.toLowerCase().includes('not found') ||
message.toLowerCase().includes('could not resolve');
const isRateLimit = message.toLowerCase().includes('rate limit');

const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`;
const errAccent = `#${(parseResult.success && parseResult.data.accent) || '58a6ff'}`;
Expand All @@ -189,6 +191,18 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
: 8;
const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';

if (isRateLimit) {
const svg = generateRateLimitSVG(errBg, errAccent, errText, errRadius, errSpeed);
return new NextResponse(svg, {
status: 429,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Content-Security-Policy': SVG_CSP_HEADER,
},
});
}

if (isNotFound) {
const match = message.match(/"([^"]+)"|login of '([^']+)'/);
const badUsername =
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/StatsCardSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function StatsCardSkeleton() {
const heights = [24, 32, 18, 45, 38, 52, 28, 42, 35, 48, 30, 22];

return (
<div className="p-6 rounded-xl bg-[#0a0a0a] border border-[rgba(255,255,255,0.08)] overflow-hidden">
<div className="p-6 rounded-xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] overflow-hidden">
<div className="flex justify-between items-start mb-6">
<div className="space-y-3">
<div className="h-3 w-24 shimmer rounded" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`StatsCardSkeleton > matches snapshot without random variation 1`] = `
<div
class="p-6 rounded-xl bg-[#0a0a0a] border border-[rgba(255,255,255,0.08)] overflow-hidden"
class="p-6 rounded-xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] overflow-hidden"
>
<div
class="flex justify-between items-start mb-6"
Expand Down
46 changes: 46 additions & 0 deletions lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
generateAchievements,
clearGitHubApiCacheForTests,
GITHUB_CACHE_TTL_MS,
fetchWithRetry,
validateGitHubUsername,
cacheKey,
buildCommitClock,
Expand Down Expand Up @@ -58,6 +59,51 @@ afterEach(() => {
}
});

describe('fetchWithRetry', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch');
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('retries on 429 with numeric retry-after', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'retry-after': '2' } }))
.mockResolvedValueOnce(new Response('ok', { status: 200 }));

const promise = fetchWithRetry('http://test', {});
await vi.advanceTimersByTimeAsync(2000);
const res = await promise;
expect(res.status).toBe(200);
expect(fetch).toHaveBeenCalledTimes(2);
});

it('retries on 403 with x-ratelimit-remaining: 0', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
new Response(null, { status: 403, headers: { 'x-ratelimit-remaining': '0' } })
)
.mockResolvedValueOnce(new Response('ok', { status: 200 }));

const promise = fetchWithRetry('http://test', {});
await vi.advanceTimersByTimeAsync(500); // default backoff for attempt 0
const res = await promise;
expect(res.status).toBe(200);
});

it('exits early without retrying if delay > 5000', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(null, { status: 429, headers: { 'retry-after': '6' } }) // 6000ms
);
const res = await fetchWithRetry('http://test', {});
expect(res.status).toBe(429);
expect(fetch).toHaveBeenCalledTimes(1);
});
});

describe('fetchGitHubContributions', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch');
Expand Down
67 changes: 65 additions & 2 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,43 @@ export async function fetchWithRetry(

clearTimeout(timeoutId);

// Only retry on 429 or 5xx — all other statuses are returned immediately
const shouldRetry = res.status === 429 || res.status >= 500;
// Check for rate limit headers
const retryAfter = res.headers.get('retry-after');
const isRateLimited =
res.status === 429 || (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0');

Comment on lines +72 to +76
if (isRateLimited) {
if (attempt >= MAX_RETRIES) return res;

let delay = BASE_DELAY_MS * Math.pow(2, attempt);
if (retryAfter) {
const parsed = parseInt(retryAfter, 10);
if (!Number.isNaN(parsed) && String(parsed) === retryAfter) {
delay = parsed * 1000;
} else {
const dateDelay = Date.parse(retryAfter) - Date.now();
if (!Number.isNaN(dateDelay) && dateDelay > 0) {
delay = dateDelay;
}
}
}

// Clamp between exponential default and maximum safe delay before we early exit anyway
delay = Math.max(BASE_DELAY_MS, delay);

// If the delay is too long (e.g., > 5 seconds), it's a hard limit.
// Return immediately to avoid serverless function timeouts.
if (delay > 5000) {
return res;
}

await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, attempt + 1, timeoutMs);
}

// Only retry on 5xx — all other statuses are returned immediately
const shouldRetry = res.status >= 500;

if (!shouldRetry || attempt >= MAX_RETRIES) return res;

const delay = BASE_DELAY_MS * Math.pow(2, attempt);
Expand Down Expand Up @@ -286,6 +321,12 @@ export async function fetchGitHubContributions(

if (!res.ok) {
if (res.status === 401) throw new Error('GitHub PAT is invalid or missing');
if (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') {
throw new Error('API Rate Limit Exceeded');
}
if (res.status === 429) {
throw new Error('API Rate Limit Exceeded');
Comment on lines +324 to +328
}
throw new Error(
`GitHub GraphQL API returned status ${res.status} after ${MAX_RETRIES} retries`
);
Expand All @@ -294,6 +335,16 @@ export async function fetchGitHubContributions(
const data: GitHubContributionResponse = await res.json();

if (data.errors !== undefined) {
if (Array.isArray(data.errors)) {
const isRateLimit = data.errors.some(
(e) =>
e?.message?.toLowerCase().includes('rate limit') ||
(e as { type?: string })?.type === 'RATE_LIMITED'
);
if (isRateLimit) {
throw new Error('API Rate Limit Exceeded');
}
}
throw new Error(getGraphQLErrorMessage(data.errors));
}

Expand Down Expand Up @@ -361,6 +412,12 @@ export async function fetchUserProfile(

if (!res.ok) {
if (res.status === 404) throw new Error('User not found');
if (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') {
throw new Error('API Rate Limit Exceeded');
}
if (res.status === 429) {
throw new Error('API Rate Limit Exceeded');
}
throw new Error(`GitHub REST API error: ${res.status}`);
}

Expand Down Expand Up @@ -455,6 +512,12 @@ export async function fetchUserRepos(
const pagesRepos = await Promise.all(
responses.map(async (response) => {
if (!response.ok) {
if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
throw new Error('API Rate Limit Exceeded');
}
if (response.status === 429) {
throw new Error('API Rate Limit Exceeded');
}
throw new Error(`GitHub REST API error: ${response.status}`);
}
return (await response.json()) as GitHubRepo[];
Expand Down
19 changes: 18 additions & 1 deletion lib/svg/generator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { generateSVG, generateMonthlySVG, particleCount, escapeXML } from './generator';
import {
generateSVG,
generateMonthlySVG,
generateRateLimitSVG,
particleCount,
escapeXML,
} from './generator';
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
import { hexColor } from './sanitizer';

Expand Down Expand Up @@ -610,3 +616,14 @@ describe('particleCount', () => {
expect(particleCount(100)).toBe(5);
});
});

describe('generateRateLimitSVG', () => {
it('generates a valid SVG with rate limit messaging', () => {
const svg = generateRateLimitSVG('#000000', '#ffffff', '#aaaaaa', 8, '8s');
expect(svg).toContain('<svg');
expect(svg).toContain('API RATE LIMIT');
expect(svg).toContain('RATE LIMITED');
expect(svg).toContain('Please wait a moment before trying again');
expect(svg).toContain('</svg>');
});
});
Loading
Loading