From d5e8200d2c787540a3d5918bcbb715b6ed66f6f6 Mon Sep 17 00:00:00 2001 From: MAC Date: Fri, 26 Jun 2026 00:26:44 +0100 Subject: [PATCH] fix(api): scope DB stale-fallback cache keys by network [BUG-006] withDbCacheFallback's dbCache Map was keyed by bare strings the caller passes in (e.g. "markets:all", "funding:global") with no network dimension, even though every live query it backs already filters by network at the DB layer. If the configured network changed between the call that populated the cache and a later call that fails (redeploy, config flip, or any shared-DB deployment with multiple networks), the stale-fallback path could silently serve one network's cached data under a different network's request. Suffix the internal cache key with getNetwork() inside withDbCacheFallback itself, so every current and future caller gets network-scoped fallback caching automatically without needing to remember to include it in the cacheKey they pass. Added a regression test: seed the cache while serving devnet, switch to mainnet, fail the live query under the same bare key, and assert a clean 503 rather than a 200 silently carrying devnet's stale data. Verified the test fails against the pre-fix code (200 with wrong-network data) and passes against the fix. Co-authored-by: Claude Sonnet 4.6 --- src/middleware/db-cache-fallback.ts | 17 ++++++--- tests/middleware/db-cache-fallback.test.ts | 40 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/middleware/db-cache-fallback.ts b/src/middleware/db-cache-fallback.ts index 6677ab3..384591d 100644 --- a/src/middleware/db-cache-fallback.ts +++ b/src/middleware/db-cache-fallback.ts @@ -4,7 +4,7 @@ * When Supabase queries fail, serve stale cached data instead of 500 errors. * This improves availability during DB outages or network issues. */ -import { createLogger, truncateErrorMessage } from "@percolator/shared"; +import { createLogger, truncateErrorMessage, getNetwork } from "@percolator/shared"; import { Context } from "hono"; const logger = createLogger("api:db-cache-fallback"); @@ -53,10 +53,17 @@ export async function withDbCacheFallback( queryFn: () => Promise, c: Context ): Promise | Response> { + // Suffix with the active network so this fallback cache can never serve a + // different network's data than the live query it's backing — every live + // query already filters by network at the DB layer, but the bare cacheKey + // strings callers pass in (e.g. "markets:all") carry no network dimension + // on their own. + const networkedKey = `${cacheKey}:${getNetwork()}`; + try { // Try the query const result = await queryFn(); - + // Evict oldest entries when at capacity while (dbCache.size >= MAX_DB_CACHE_ENTRIES) { const oldest = dbCache.keys().next().value; @@ -65,7 +72,7 @@ export async function withDbCacheFallback( } // Cache successful result - dbCache.set(cacheKey, { + dbCache.set(networkedKey, { data: result, timestamp: Date.now(), }); @@ -76,9 +83,9 @@ export async function withDbCacheFallback( error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), cacheKey, }); - + // Check if we have cached data - const cached = dbCache.get(cacheKey); + const cached = dbCache.get(networkedKey); if (cached) { const age = Date.now() - cached.timestamp; diff --git a/tests/middleware/db-cache-fallback.test.ts b/tests/middleware/db-cache-fallback.test.ts index 2d01e60..ad19c46 100644 --- a/tests/middleware/db-cache-fallback.test.ts +++ b/tests/middleware/db-cache-fallback.test.ts @@ -9,9 +9,11 @@ vi.mock("@percolator/shared", () => ({ debug: vi.fn(), })), truncateErrorMessage: vi.fn((s: string) => s), + getNetwork: vi.fn(() => "devnet"), })); import { withDbCacheFallback, clearDbCache } from "../../src/middleware/db-cache-fallback.js"; +const { getNetwork } = await import("@percolator/shared"); function makeApp(handler: (c: any) => Promise) { const app = new Hono(); @@ -22,6 +24,7 @@ function makeApp(handler: (c: any) => Promise) { describe("withDbCacheFallback", () => { beforeEach(() => { clearDbCache(); + vi.mocked(getNetwork).mockReturnValue("devnet" as any); }); it("returns DbCacheResult on success with stale=false and ok=true", async () => { @@ -98,4 +101,41 @@ describe("withDbCacheFallback", () => { expect(res.headers.get("X-Cache-Status")).toBe("stale-fallback"); expect(res.headers.get("Warning")).toMatch(/^110 - "Response is Stale/); }); + + it("does not serve a different network's stale cache after the configured network changes (BUG-006)", async () => { + // Seed the cache while serving devnet. + vi.mocked(getNetwork).mockReturnValue("devnet" as any); + const seedApp = makeApp(async (c) => { + const result = await withDbCacheFallback( + "test:network-switch", + async () => ({ network: "devnet-data" }), + c, + ); + if (result instanceof Response) return result; + return c.json(result.data); + }); + const seed = await seedApp.request("/test"); + expect(seed.status).toBe(200); + + // Simulate the deployment's configured network changing (e.g. redeploy/ + // config flip) while the live query for the SAME bare cacheKey fails. + vi.mocked(getNetwork).mockReturnValue("mainnet" as any); + const fallbackApp = makeApp(async (c) => { + const result = await withDbCacheFallback( + "test:network-switch", + async () => { + throw new Error("DB down"); + }, + c, + ); + if (result instanceof Response) return result; + return c.json(result.data); + }); + + const res = await fallbackApp.request("/test"); + // Must NOT silently serve devnet's stale data under the mainnet + // context — there is no mainnet-keyed cache entry, so this must be a + // clean 503, not a 200 carrying the wrong network's data. + expect(res.status).toBe(503); + }); });