Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/runtime/internal/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
// 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;
Expand All @@ -119,7 +129,18 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
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 };
Expand Down
7 changes: 7 additions & 0 deletions test/fixture/api/cached-error-toggle.ts
Original file line number Diff line number Diff line change
@@ -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" };
});
13 changes: 13 additions & 0 deletions test/fixture/api/cached-error.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
5 changes: 5 additions & 0 deletions test/fixture/utils/cached-error-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export let cachedErrorShouldError = false;

export function setCachedError(value: boolean) {
cachedErrorShouldError = value;
}
10 changes: 10 additions & 0 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
33 changes: 33 additions & 0 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down