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
27 changes: 11 additions & 16 deletions scripts/fetch-user-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
50 changes: 50 additions & 0 deletions scripts/sync-leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
77 changes: 74 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
});
}
Expand Down
Loading