diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 809b290..c3211ee 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { fetchGitHubUserData } from "../../../lib/github"; +import { fetchGitHubUserData, RateLimitError } from "../../../lib/github"; import { calculateUserScore } from "../../../lib/score"; export const runtime = "nodejs"; @@ -33,9 +33,27 @@ export async function GET(request: Request) { }) ); - return NextResponse.json({ success: true, users: results }); + return NextResponse.json( + { success: true, users: results }, + { headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" } } + ); } catch (error: any) { console.error("GitHub score error:", error); + + if (error instanceof RateLimitError) { + return NextResponse.json( + { + success: false, + error: "GitHub API rate limit exceeded. Please try again later.", + retryAfter: error.retryAfter, + }, + { + status: 429, + headers: { "Retry-After": String(error.retryAfter) }, + } + ); + } + const message = error?.message === "User not found" ? "GitHub user not found" diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..c938b9b --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,41 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +const store = new Map>(); + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +export function getCached(key: string): T | undefined { + const entry = store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + store.delete(key); + return undefined; + } + return entry.data as T; +} + +export function getStale(key: string): T | undefined { + const entry = store.get(key); + if (!entry) return undefined; + return entry.data as T; +} + +export function setCached(key: string, data: T, ttlMs = DEFAULT_TTL_MS) { + store.set(key, { data, expiresAt: Date.now() + ttlMs }); +} + +export function cacheStats() { + let valid = 0; + const now = Date.now(); + for (const [key, entry] of store) { + if (now > entry.expiresAt) { + store.delete(key); + } else { + valid++; + } + } + return { size: valid }; +} diff --git a/lib/github.ts b/lib/github.ts index 4023aac..a6128c5 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -1,5 +1,6 @@ import { ContributionTotals, GitHubUserData, PullRequestNode, RepoNode } from "@/types/github"; import { graphql } from "@octokit/graphql"; +import { getCached, getStale, setCached } from "./cache"; if (!process.env.GITHUB_TOKEN) { throw new Error("Missing GITHUB_TOKEN"); @@ -57,18 +58,53 @@ const QUERY = /* GraphQL */ ` } `; +export class RateLimitError extends Error { + retryAfter: number; + constructor(retryAfter: number) { + super("GitHub API rate limit exceeded"); + this.name = "RateLimitError"; + this.retryAfter = retryAfter; + } +} + +function isRateLimitError(error: any): boolean { + const status = error?.status ?? error?.response?.status; + return status === 403 || status === 429; +} + export async function fetchGitHubUserData( username: string ): Promise { - const { user } = await client<{ user: any }>(QUERY, { login: username }); + const cacheKey = `github:${username.toLowerCase()}`; + const cached = getCached(cacheKey); + if (cached) return cached; - if (!user) { - throw new Error("User not found"); - } + try { + const { user } = await client<{ user: any }>(QUERY, { login: username }); - return { - repos: user.repositories.nodes as RepoNode[], - pullRequests: user.pullRequests.nodes as PullRequestNode[], - contributions: user.contributionsCollection as ContributionTotals, - }; + if (!user) { + throw new Error("User not found"); + } + + const data: GitHubUserData = { + repos: user.repositories.nodes as RepoNode[], + pullRequests: user.pullRequests.nodes as PullRequestNode[], + contributions: user.contributionsCollection as ContributionTotals, + }; + + setCached(cacheKey, data); + return data; + } catch (error: any) { + if (isRateLimitError(error)) { + const stale = getStale(cacheKey); + if (stale) return stale; + + const resetAt = error.response?.headers?.["x-ratelimit-reset"]; + const retryAfter = resetAt + ? Math.max(0, Number(resetAt) - Math.floor(Date.now() / 1000)) + : 60; + throw new RateLimitError(retryAfter); + } + throw error; + } }