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 }; diff --git a/test/fixture/api/cached-error-toggle.ts b/test/fixture/api/cached-error-toggle.ts new file mode 100644 index 0000000000..5de87cbbe5 --- /dev/null +++ b/test/fixture/api/cached-error-toggle.ts @@ -0,0 +1,7 @@ +import { setCachedError } from "../utils/cached-error-state"; + +export default defineEventHandler((event) => { + const { error } = getQuery(event); + setCachedError(error === "true"); + return { shouldError: error === "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..6e9e7e3330 --- /dev/null +++ b/test/fixture/utils/cached-error-state.ts @@ -0,0 +1,5 @@ +export let cachedErrorShouldError = false; + +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 6518d22aa3..dac6206847 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -755,6 +755,39 @@ 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", + }); + expect(status).toBe(200); + expect(data.timestamp).toBeDefined(); + + // 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)); + + // 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", () => {