learning_ai_invt_trdg/backend/src/services/fmpCache.ts
Saravana Achu Mac 082800745c fix(C2): cache FMP upstream responses
Add a 30-minute in-memory cache keyed by the complete Financial Modeling Prep upstream URL so repeated profile, metrics, earnings, and screener requests do not burn the free-tier quota. The cache keeps non-OK responses out of storage so transient rate-limit or provider errors can recover on the next request.

Refs: docs/AUDIT_REDESIGN.md item C2.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
2026-05-04 16:06:47 -07:00

73 lines
1.7 KiB
TypeScript

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<unknown>;
}>;
interface FmpCacheEntry {
expiresAt: number;
data: unknown;
}
const fmpCache = new Map<string, FmpCacheEntry>();
const inFlightFmpRequests = new Map<string, Promise<unknown>>();
let now = () => Date.now();
export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise<unknown> {
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;
}