Skip to content
Merged
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
89 changes: 61 additions & 28 deletions src/app/api/metrics/pinned-repos/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { getServerSession } from "next-auth";
import type { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { GitHubAuthError, githubAuthErrorResponse } from "@/lib/github-fetch";
import {
GitHubApiError,
GitHubAuthError,
GitHubRateLimitError,
githubAuthErrorResponse,
githubGraphQL,
} from "@/lib/github-fetch";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";

Expand All @@ -13,6 +26,14 @@ interface PinnedRepo {
primaryLanguage: { name: string; color: string } | null;
}

interface PinnedReposQueryResult {
viewer?: {
pinnedItems?: {
nodes?: Array<PinnedRepo | null | undefined>;
};
};
}

const PINNED_REPOS_QUERY = `
query {
viewer {
Expand All @@ -35,47 +56,59 @@ const PINNED_REPOS_QUERY = `
}
`;

export async function GET() {
export async function GET(req?: NextRequest) {
const session = await getServerSession(authOptions);

if (!session?.accessToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

if (session.error === "TokenRevoked") {
return githubAuthErrorResponse();
}

const accessToken = session.accessToken;
const cacheUserId = session.githubId ?? session.githubLogin;

if (!cacheUserId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const bypass = req ? isMetricsCacheBypassed(req) : false;
const key = metricsCacheKey(cacheUserId, "pinned-repos");

try {
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
const result = await withMetricsCache(
{
bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS["pinned-repos"],
fallbackToStaleOnError: (error) =>
error instanceof GitHubRateLimitError,
},
body: JSON.stringify({ query: PINNED_REPOS_QUERY }),
cache: "no-store",
});

if (!response.ok) {
if (response.status === 401) return githubAuthErrorResponse();
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
async () => {
const data = await githubGraphQL<PinnedReposQueryResult>(
PINNED_REPOS_QUERY,
accessToken
);

const data = (await response.json()) as {
data?: {
viewer?: {
pinnedItems?: {
nodes?: Array<PinnedRepo | null | undefined>;
};
};
};
};
const nodes = (data.viewer?.pinnedItems?.nodes ?? []).filter(
(node): node is PinnedRepo => node != null
);

const nodes = (data.data?.viewer?.pinnedItems?.nodes ?? []).filter(
(node): node is PinnedRepo => node != null
return { pinnedRepos: nodes };
}
);

return Response.json({ pinnedRepos: nodes });
} catch (e) {
return Response.json(result);
} catch (error) {
if (
error instanceof GitHubAuthError ||
(error instanceof GitHubApiError && error.status === 401)
) {
return githubAuthErrorResponse();
}

return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
86 changes: 69 additions & 17 deletions src/lib/metrics-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const METRICS_CACHE_TTL_SECONDS = {
"productive-hours": 5 * 60,
discussions: 10 * 60,
repos: 10 * 60,
"pinned-repos": 10 * 60,
"inactive-repos": 10 * 60,
prs: 10 * 60,
"pr-review-time": 10 * 60,
Expand All @@ -25,6 +26,19 @@ export const METRICS_CACHE_TTL_SECONDS = {
type MetricsCacheEndpoint = keyof typeof METRICS_CACHE_TTL_SECONDS;
type CacheParamValue = boolean | number | string | null | undefined;
type MemoryCacheEntry = { value: unknown; expiresAt: number };
export const DEFAULT_METRICS_STALE_GRACE_SECONDS = 24 * 60 * 60;

export type MetricsCacheOptions = {
bypass: boolean;
key: string;
ttlSeconds: number;
staleGraceSeconds?: number;
fallbackToStaleOnError?: (error: unknown) => boolean;
};

function staleMetricsCacheKey(key: string): string {
return `${key}:stale`;
}

let redisClient: Redis | null | undefined;
const MAX_MEMORY_CACHE_ENTRIES = 500;
Expand Down Expand Up @@ -132,7 +146,7 @@ export function metricsCacheKey(

Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null)
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.forEach(([key, value]) => cacheParams.set(key, String(value)));

return `metrics:${userId}:${endpoint}:${cacheParams.toString() || "default"}`;
Expand All @@ -148,7 +162,7 @@ export async function cacheGet<T>(
}

const redis = getRedisClient();

if (redis) {
try {
const redisValue = await redis.get<T>(key);
Expand All @@ -160,7 +174,7 @@ export async function cacheGet<T>(
return null;
}
}

return null;
}

Expand All @@ -174,7 +188,7 @@ export async function cacheSet<T>(
}

const redis = getRedisClient();

if (redis) {
try {
await redis.set(key, value, { ex: ttlSeconds });
Expand All @@ -187,23 +201,49 @@ export async function cacheSet<T>(
}

export async function withMetricsCache<T>(
options: {
bypass: boolean;
key: string;
ttlSeconds: number;
},
options: MetricsCacheOptions,
loadFresh: () => Promise<T>
): Promise<T> {
let staleValue: T | null = null;

if (!options.bypass) {
const cached = await cacheGet<T>(options.key, options.ttlSeconds);

if (cached !== null) {
return cached;
}

if (options.fallbackToStaleOnError) {
staleValue = await cacheGet<T>(staleMetricsCacheKey(options.key));
}
}

const fresh = await loadFresh();
await cacheSet(options.key, fresh, options.ttlSeconds);
return fresh;
try {
const fresh = await loadFresh();

await cacheSet(options.key, fresh, options.ttlSeconds);

if (options.fallbackToStaleOnError) {
const staleGraceSeconds =
options.staleGraceSeconds ?? DEFAULT_METRICS_STALE_GRACE_SECONDS;

if (Number.isFinite(staleGraceSeconds) && staleGraceSeconds > 0) {
await cacheSet(
staleMetricsCacheKey(options.key),
fresh,
options.ttlSeconds + staleGraceSeconds
);
}
}

return fresh;
} catch (error) {
if (staleValue !== null && options.fallbackToStaleOnError?.(error)) {
return staleValue;
}

throw error;
}
}

/**
Expand All @@ -212,19 +252,25 @@ export async function withMetricsCache<T>(
* scanning all keys the way invalidateUserMetricsCache does for per-user data.
*/
export async function cacheDelete(key: string): Promise<void> {
memoryCache.delete(key);
const keys = [key, staleMetricsCacheKey(key)];

for (const cacheKey of keys) {
memoryCache.delete(cacheKey);
}

const redis = getRedisClient();
if (!redis) return;

try {
await redis.del(key);
await redis.del(...keys);
} catch {
// Cache invalidation failures must not surface to callers.
}
}

export async function invalidateUserMetricsCache(userId: string): Promise<void> {
export async function invalidateUserMetricsCache(
userId: string
): Promise<void> {
const prefix = `metrics:${userId}:`;

for (const key of memoryCache.keys()) {
Expand All @@ -239,7 +285,10 @@ export async function invalidateUserMetricsCache(userId: string): Promise<void>
try {
let cursor = 0;
do {
const [nextCursor, keys] = await redis.scan(cursor, { match: `${prefix}*`, count: 100 });
const [nextCursor, keys] = await redis.scan(cursor, {
match: `${prefix}*`,
count: 100,
});
if (keys.length > 0) {
await redis.del(...keys);
}
Expand All @@ -265,7 +314,10 @@ export async function invalidateLeaderboardCache(): Promise<void> {
try {
let cursor = 0;
do {
const [nextCursor, keys] = await redis.scan(cursor, { match: `${prefix}*`, count: 100 });
const [nextCursor, keys] = await redis.scan(cursor, {
match: `${prefix}*`,
count: 100,
});
if (keys.length > 0) {
await redis.del(...keys);
}
Expand Down
Loading
Loading