export const FMP_CACHE_TTL_MS = 30 * 60 * 1000; export class FmpFetchError extends Error { constructor(public readonly status: number) { super(`FMP fetch failed with status ${status}`); this.name = 'FmpFetchError'; } } type FetchJson = (url: string) => Promise<{ ok: boolean; status: number; json: () => Promise; }>; interface FmpCacheEntry { expiresAt: number; data: unknown; } const fmpCache = new Map(); const inFlightFmpRequests = new Map>(); let now = () => Date.now(); export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise { const cached = fmpCache.get(url); const currentTime = now(); if (cached && cached.expiresAt > currentTime) { return cached.data; } if (cached) { fmpCache.delete(url); } const inFlight = inFlightFmpRequests.get(url); if (inFlight) { return inFlight; } const request = fetchJson(url) .then(async (response) => { if (!response.ok) { throw new FmpFetchError(response.status); } const data = await response.json(); fmpCache.set(url, { data, expiresAt: now() + FMP_CACHE_TTL_MS, }); return data; }) .finally(() => { inFlightFmpRequests.delete(url); }); inFlightFmpRequests.set(url, request); return request; } export function clearFmpCacheForTests() { fmpCache.clear(); inFlightFmpRequests.clear(); now = () => Date.now(); } export function setFmpCacheNowForTests(nowProvider: () => number) { now = nowProvider; }