From 18cba755f887973fc913ebc78e9c14fa1a80fe16 Mon Sep 17 00:00:00 2001 From: aoi-dev-0411 Date: Sat, 11 Apr 2026 11:41:54 +0900 Subject: [PATCH 1/2] feat: implement caching for GitHub API responses Add in-memory cache with 5-minute TTL for GitHub GraphQL responses. Duplicate requests for the same username are served from cache, reducing API calls and improving response times. - Add lib/cache.ts with generic get/set/stats helpers - Wrap fetchGitHubUserData with cache lookup - Add Cache-Control header to compare API response Closes #33 --- app/api/compare/route.ts | 5 ++++- lib/cache.ts | 35 +++++++++++++++++++++++++++++++++++ lib/github.ts | 10 +++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 lib/cache.ts diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 809b290..78306d7 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -33,7 +33,10 @@ 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); const message = diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..5a9db2a --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,35 @@ +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 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..0dd995e 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, setCached } from "./cache"; if (!process.env.GITHUB_TOKEN) { throw new Error("Missing GITHUB_TOKEN"); @@ -60,15 +61,22 @@ const QUERY = /* GraphQL */ ` export async function fetchGitHubUserData( username: string ): Promise { + const cacheKey = `github:${username.toLowerCase()}`; + const cached = getCached(cacheKey); + if (cached) return cached; + const { user } = await client<{ user: any }>(QUERY, { login: username }); if (!user) { throw new Error("User not found"); } - return { + 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; } From 701ff84cc6587f0f75f7e3aec964103940b44df3 Mon Sep 17 00:00:00 2001 From: aoi-dev-0411 Date: Sat, 11 Apr 2026 11:43:19 +0900 Subject: [PATCH 2/2] feat: handle GitHub API rate limits gracefully When the GitHub API returns 403/429 (rate limited), the app now: 1. Falls back to stale cached data if available 2. Returns a friendly 429 response with Retry-After header when no cached data exists Adds RateLimitError class and stale cache lookup helper. Closes #32 --- app/api/compare/route.ts | 17 ++++++++++++- lib/cache.ts | 6 +++++ lib/github.ts | 52 ++++++++++++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 78306d7..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"; @@ -39,6 +39,21 @@ export async function GET(request: Request) { ); } 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 index 5a9db2a..c938b9b 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -17,6 +17,12 @@ export function getCached(key: string): T | 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 }); } diff --git a/lib/github.ts b/lib/github.ts index 0dd995e..a6128c5 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -1,6 +1,6 @@ import { ContributionTotals, GitHubUserData, PullRequestNode, RepoNode } from "@/types/github"; import { graphql } from "@octokit/graphql"; -import { getCached, setCached } from "./cache"; +import { getCached, getStale, setCached } from "./cache"; if (!process.env.GITHUB_TOKEN) { throw new Error("Missing GITHUB_TOKEN"); @@ -58,6 +58,20 @@ 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 { @@ -65,18 +79,32 @@ export async function fetchGitHubUserData( const cached = getCached(cacheKey); if (cached) return cached; - const { user } = await client<{ user: any }>(QUERY, { login: username }); + try { + const { user } = await client<{ user: any }>(QUERY, { login: username }); - if (!user) { - throw new Error("User not found"); - } + 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, + }; - 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; - setCached(cacheKey, data); - return data; + 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; + } }