Skip to content
Closed
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
22 changes: 20 additions & 2 deletions app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
interface CacheEntry<T> {
data: T;
expiresAt: number;
}

const store = new Map<string, CacheEntry<unknown>>();

const DEFAULT_TTL_MS = 5 * 60 * 1000;

export function getCached<T>(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<T>(key: string): T | undefined {
const entry = store.get(key);
if (!entry) return undefined;
return entry.data as T;
}

export function setCached<T>(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 };
}
54 changes: 45 additions & 9 deletions lib/github.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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<GitHubUserData> {
const { user } = await client<{ user: any }>(QUERY, { login: username });
const cacheKey = `github:${username.toLowerCase()}`;
const cached = getCached<GitHubUserData>(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<GitHubUserData>(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;
}
}
Loading