diff --git a/scripts/fetch-user-info.js b/scripts/fetch-user-info.js index df905505..bc02648a 100644 --- a/scripts/fetch-user-info.js +++ b/scripts/fetch-user-info.js @@ -50,23 +50,18 @@ async function fetchUserInfo(username) { } // 2. Fetch live profile ranking from the wrapper API - const livePromise = fetch(liveApiUrl) - .then(async (res) => { - if (res.ok) { - const apiData = await res.json(); - ranking = apiData.ranking || 0; - contest = apiData.contest || null; - } - }) - .catch((err) => - console.error( - "Failed to fetch live profile ranking from API wrapper:", - err.message, - ), - ); + const livePromise = fetch(liveApiUrl).then(async (res) => { + if (res.ok) { + const apiData = await res.json(); + ranking = apiData.ranking || 0; + contest = apiData.contest || null; + } else { + throw new Error(`LeetCode API wrapper returned status ${res.status}`); + } + }); - // Wait for the concurrent live API task to complete - await Promise.allSettled([livePromise]); + // Wait for the live API task to complete + await livePromise; // Ensure history is sorted chronologically history.sort((a, b) => new Date(a.date) - new Date(b.date)); diff --git a/scripts/sync-leaderboard.js b/scripts/sync-leaderboard.js index 994744cc..a4c8f6da 100644 --- a/scripts/sync-leaderboard.js +++ b/scripts/sync-leaderboard.js @@ -4,6 +4,56 @@ const axios = require("axios"); const fs = require("fs"); const path = require("path"); +// Configure retry settings on axios response +const MAX_RETRIES = 3; +const INITIAL_DELAY_MS = 1000; + +axios.interceptors.response.use( + (response) => response, + async (error) => { + const { config } = error; + + if (!config) { + return Promise.reject(error); + } + + config.__retryCount = config.__retryCount || 0; + + const isRateLimited = error.response && error.response.status === 429; + const isServerError = + error.response && + error.response.status >= 500 && + error.response.status <= 599; + const isNetworkError = + !error.response || + error.code === "ECONNABORTED" || + error.message.includes("timeout"); + + const shouldRetry = + (isRateLimited || isServerError || isNetworkError) && + config.__retryCount < MAX_RETRIES; + + if (shouldRetry) { + config.__retryCount += 1; + + // Exponential backoff with jitter + const backoffDelay = + INITIAL_DELAY_MS * Math.pow(2, config.__retryCount - 1); + const jitter = Math.random() * 200; + const delay = backoffDelay + jitter; + + console.warn( + `[Retry ${config.__retryCount}/${MAX_RETRIES}] Request failed for ${config.url} (${error.message}). Retrying in ${Math.round(delay)}ms...`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + return axios(config); + } + + return Promise.reject(error); + }, +); + function atomicWrite(filePath, data) { const dir = path.dirname(filePath); const ext = path.extname(filePath); diff --git a/server.js b/server.js index 8ab527e7..ae6a3c3c 100644 --- a/server.js +++ b/server.js @@ -142,6 +142,22 @@ const apiLimiter = rateLimit({ }, }); +// ---- Cache configuration ---- +const userCache = new Map(); +const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes + +// Helper to prune cache to bound memory usage +function pruneUserCache() { + if (userCache.size > 1000) { + const now = Date.now(); + for (const [key, value] of userCache.entries()) { + if (now - value.timestamp > CACHE_TTL_MS) { + userCache.delete(key); + } + } + } +} + app.use("/api/user/:username", apiLimiter); app.get("/api/user/:username", async (req, res) => { @@ -152,12 +168,67 @@ app.get("/api/user/:username", async (req, res) => { return res.status(400).json({ error: "Invalid username format" }); } + const cached = userCache.get(username); + const now = Date.now(); + + if (cached) { + if (now - cached.timestamp < CACHE_TTL_MS && cached.data) { + return res.json(cached.data); + } + + if (cached.promise) { + try { + const data = await cached.promise; + return res.json(data); + } catch (err) { + if (cached.data) { + console.warn( + `[Cache Fallback] Serving stale data after pending fetch failed...`, + ); + return res.json(cached.data); + } + } + } + } + + let fetchPromise; try { - const data = await fetchUserInfo(username); + fetchPromise = fetchUserInfo(username); + + userCache.set(username, { + ...cached, + timestamp: cached ? cached.timestamp : 0, + promise: fetchPromise, + }); + + const data = await fetchPromise; + + pruneUserCache(); + userCache.set(username, { + timestamp: Date.now(), + data, + promise: null, + }); + res.json(data); } catch (err) { - res.status(500).json({ - error: "Failed to fetch user details", + userCache.set(username, { + ...cached, + promise: null, + }); + + if (cached && cached.data) { + console.warn( + `[Cache Fallback] Failed to fetch fresh data for user: ${username}. Serving stale cached data. Error: ${err.message}`, + ); + return res.json(cached.data); + } + + console.error( + `[Cache Error] Failed to fetch data for user: ${username} (No cached fallback available). Error: ${err.message}`, + ); + res.status(502).json({ + error: "Failed to fetch user details from external LeetCode API wrapper", details: err.message, }); }