From 18bbf4e2efca73d6875785692e3129ec931204c2 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 26 Feb 2026 19:06:30 +0100 Subject: [PATCH 1/4] fix: remove stale cache --- src/runtime/internal/cache.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/runtime/internal/cache.ts b/src/runtime/internal/cache.ts index f848b82c79..500daa6fa1 100644 --- a/src/runtime/internal/cache.ts +++ b/src/runtime/internal/cache.ts @@ -109,6 +109,16 @@ export function defineCachedFunction( // Make sure entries that reject get removed. if (!isPending) { delete pending[key]; + // Remove stale cache entry when resolver throws (e.g. handler returns 404) + const promise = useStorage() + .removeItem(cacheKey) + .catch((error) => { + console.error(`[cache] Cache remove error.`, error); + useNitroApp().captureError(error, { event, tags: ["cache"] }); + }); + if (event?.waitUntil) { + event.waitUntil(promise); + } } // Re-throw error to make sure the caller knows the task failed. throw error; @@ -119,7 +129,18 @@ export function defineCachedFunction( entry.mtime = Date.now(); entry.integrity = integrity; delete pending[key]; - if (validate(entry) !== false) { + if (validate(entry) === false) { + // Remove stale cache entry when revalidation produces an invalid response (e.g. 404) + const promise = useStorage() + .removeItem(cacheKey) + .catch((error) => { + console.error(`[cache] Cache remove error.`, error); + useNitroApp().captureError(error, { event, tags: ["cache"] }); + }); + if (event?.waitUntil) { + event.waitUntil(promise); + } + } else { let setOpts: TransactionOptions | undefined; if (opts.maxAge && !opts.swr /* TODO: respect staleMaxAge */) { setOpts = { ttl: opts.maxAge }; From 9a2033886fd1f012bcf606fd820a6a67e8e3d095 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 26 Feb 2026 19:07:14 +0100 Subject: [PATCH 2/4] chore: add test --- test/fixture/api/cached-error-toggle.ts | 6 +++++ test/fixture/api/cached-error.ts | 13 +++++++++++ test/fixture/utils/cached-error-state.ts | 5 +++++ test/tests.ts | 28 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 test/fixture/api/cached-error-toggle.ts create mode 100644 test/fixture/api/cached-error.ts create mode 100644 test/fixture/utils/cached-error-state.ts diff --git a/test/fixture/api/cached-error-toggle.ts b/test/fixture/api/cached-error-toggle.ts new file mode 100644 index 0000000000..36427e4777 --- /dev/null +++ b/test/fixture/api/cached-error-toggle.ts @@ -0,0 +1,6 @@ +import { toggleCachedError } from "../utils/cached-error-state"; + +export default defineEventHandler(() => { + toggleCachedError(); + return { toggled: true }; +}); diff --git a/test/fixture/api/cached-error.ts b/test/fixture/api/cached-error.ts new file mode 100644 index 0000000000..4e6b26f214 --- /dev/null +++ b/test/fixture/api/cached-error.ts @@ -0,0 +1,13 @@ +import { cachedErrorShouldError } from "../utils/cached-error-state"; + +export default defineCachedEventHandler( + () => { + if (cachedErrorShouldError) { + throw createError({ statusCode: 404, statusMessage: "Not found" }); + } + return { + timestamp: Date.now(), + }; + }, + { swr: true, maxAge: 1 } +); diff --git a/test/fixture/utils/cached-error-state.ts b/test/fixture/utils/cached-error-state.ts new file mode 100644 index 0000000000..889b9efdb4 --- /dev/null +++ b/test/fixture/utils/cached-error-state.ts @@ -0,0 +1,5 @@ +export let cachedErrorShouldError = false; + +export function toggleCachedError() { + cachedErrorShouldError = !cachedErrorShouldError; +} diff --git a/test/tests.ts b/test/tests.ts index 6518d22aa3..80b8e4e5e6 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -755,6 +755,34 @@ export function testNitro( } } ); + + it.skipIf(ctx.isIsolated || (isWindows && ctx.preset === "nitro-dev"))( + "should invalidate cache when SWR revalidation returns error", + async () => { + // 1. Prime the cache with a successful response + const { data, status } = await callHandler({ url: "/api/cached-error" }); + expect(status).toBe(200); + expect(data.timestamp).toBeDefined(); + + // 2. Toggle error state so handler throws 404 + await callHandler({ url: "/api/cached-error-toggle" }); + + // 3. Wait for cache to expire (maxAge: 1 second) + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // 4. First request after expiry: SWR serves stale, triggers background revalidation + const staleResult = await callHandler({ url: "/api/cached-error" }); + expect(staleResult.status).toBe(200); + expect(staleResult.data.timestamp).toBe(data.timestamp); + + // 5. Wait for background revalidation to complete and remove cache entry + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 6. Subsequent request should return 404 (cache entry removed) + const errorResult = await callHandler({ url: "/api/cached-error" }); + expect(errorResult.status).toBe(404); + } + ); }); describe("scanned files", () => { From d1c6e08a4ee2a843c8cba17ed25eb157e0eaac81 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 26 Feb 2026 19:13:27 +0100 Subject: [PATCH 3/4] fix: lint --- test/tests.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/tests.ts b/test/tests.ts index 80b8e4e5e6..c0b48a6550 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -760,7 +760,9 @@ export function testNitro( "should invalidate cache when SWR revalidation returns error", async () => { // 1. Prime the cache with a successful response - const { data, status } = await callHandler({ url: "/api/cached-error" }); + const { data, status } = await callHandler({ + url: "/api/cached-error", + }); expect(status).toBe(200); expect(data.timestamp).toBeDefined(); From 3d0a1cec196051a9af39fc89fe600bd6b79a15a4 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 26 Feb 2026 19:33:36 +0100 Subject: [PATCH 4/4] fix: test --- test/fixture/api/cached-error-toggle.ts | 9 +++++---- test/fixture/utils/cached-error-state.ts | 4 ++-- test/presets/vercel.test.ts | 10 ++++++++++ test/tests.ts | 7 +++++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/test/fixture/api/cached-error-toggle.ts b/test/fixture/api/cached-error-toggle.ts index 36427e4777..5de87cbbe5 100644 --- a/test/fixture/api/cached-error-toggle.ts +++ b/test/fixture/api/cached-error-toggle.ts @@ -1,6 +1,7 @@ -import { toggleCachedError } from "../utils/cached-error-state"; +import { setCachedError } from "../utils/cached-error-state"; -export default defineEventHandler(() => { - toggleCachedError(); - return { toggled: true }; +export default defineEventHandler((event) => { + const { error } = getQuery(event); + setCachedError(error === "true"); + return { shouldError: error === "true" }; }); diff --git a/test/fixture/utils/cached-error-state.ts b/test/fixture/utils/cached-error-state.ts index 889b9efdb4..6e9e7e3330 100644 --- a/test/fixture/utils/cached-error-state.ts +++ b/test/fixture/utils/cached-error-state.ts @@ -1,5 +1,5 @@ export let cachedErrorShouldError = false; -export function toggleCachedError() { - cachedErrorShouldError = !cachedErrorShouldError; +export function setCachedError(value: boolean) { + cachedErrorShouldError = value; } diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 4d3a8c5416..6c274adad4 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -337,6 +337,14 @@ describe("nitro:preset:vercel", async () => { "dest": "/api/db", "src": "/api/db", }, + { + "dest": "/api/cached-error-toggle", + "src": "/api/cached-error-toggle", + }, + { + "dest": "/api/cached-error", + "src": "/api/cached-error", + }, { "dest": "/api/cached", "src": "/api/cached", @@ -469,6 +477,8 @@ describe("nitro:preset:vercel", async () => { "functions/__fallback.func/node_modules", "functions/__fallback.func/package.json", "functions/__fallback.func/timing.js", + "functions/api/cached-error-toggle.func (symlink)", + "functions/api/cached-error.func (symlink)", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", "functions/api/echo.func (symlink)", diff --git a/test/tests.ts b/test/tests.ts index c0b48a6550..dac6206847 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -759,6 +759,9 @@ export function testNitro( it.skipIf(ctx.isIsolated || (isWindows && ctx.preset === "nitro-dev"))( "should invalidate cache when SWR revalidation returns error", async () => { + // 0. Reset error state + await callHandler({ url: "/api/cached-error-toggle?error=false" }); + // 1. Prime the cache with a successful response const { data, status } = await callHandler({ url: "/api/cached-error", @@ -766,8 +769,8 @@ export function testNitro( expect(status).toBe(200); expect(data.timestamp).toBeDefined(); - // 2. Toggle error state so handler throws 404 - await callHandler({ url: "/api/cached-error-toggle" }); + // 2. Enable error state so handler throws 404 + await callHandler({ url: "/api/cached-error-toggle?error=true" }); // 3. Wait for cache to expire (maxAge: 1 second) await new Promise((resolve) => setTimeout(resolve, 1100));