From 9626b95d5edd10a128e92fabdaf233d2d9cdd94c Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:01:09 -0700 Subject: [PATCH] fix: treat maxAge 0 as "do not cache" keyv and cache-manager read a ttl of 0 as "no expiry", so a route configured with maxAge 0 was stored forever - while, with cacheControl enabled, the response itself told clients max-age=0. A maxAge of 0 or less now stores nothing: recv returns the response without writing an entry or an ETag, and cacheStore.set refuses such a lifetime for any caller. Positive maxAge values are unchanged. --- docs/guide/caching/index.md | 6 ++ .../server/src/middlewares/recv.ts | 10 +++ .../server/src/services/cacheStore.ts | 4 ++ shared/tests/max-age-zero.test.js | 66 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 shared/tests/max-age-zero.test.js diff --git a/docs/guide/caching/index.md b/docs/guide/caching/index.md index 901f109..bfa5ef9 100644 --- a/docs/guide/caching/index.md +++ b/docs/guide/caching/index.md @@ -158,6 +158,12 @@ store, so a configured hour lived 41.7 days and nothing ever expired. `maxAge` can be set at three levels, each inheriting from the one above: `strategy` → content type → route. A route that overrides it wins. +`maxAge: 0` means **do not cache**: the response goes to the client as normal +and nothing is stored, so every request reaches the origin. (Before 5.1.1 a 0 +was handed to the store as a lifetime, which it reads as "never expires", so +such a route was cached forever.) Identical requests arriving at the same +moment are still [coalesced](#request-coalescing) into one origin call. + ## hitpass `hitpass` decides, per request, whether to bypass the cache entirely. It is a diff --git a/packages/plugin-rest-cache/server/src/middlewares/recv.ts b/packages/plugin-rest-cache/server/src/middlewares/recv.ts index 3784974..35c3028 100644 --- a/packages/plugin-rest-cache/server/src/middlewares/recv.ts +++ b/packages/plugin-rest-cache/server/src/middlewares/recv.ts @@ -404,6 +404,16 @@ export default function createRecv( // isCacheable has already read whatever Cache-Control the handler set. setCacheControl(ctx); + // A route with maxAge 0 (or less) is not cached: no entry, and no ETag the + // server could never revalidate. Its Cache-Control, when enabled, already + // says max-age=0. + if (maxAge <= 0) { + debug('strapi:plugin-rest-cache')( + `[RECV] GET ${cacheKey} ${colors.grey('not cached: maxAge 0')}` + ); + return; + } + { const writes: Array> = []; diff --git a/packages/plugin-rest-cache/server/src/services/cacheStore.ts b/packages/plugin-rest-cache/server/src/services/cacheStore.ts index 8d3e935..b318b79 100644 --- a/packages/plugin-rest-cache/server/src/services/cacheStore.ts +++ b/packages/plugin-rest-cache/server/src/services/cacheStore.ts @@ -189,6 +189,10 @@ export default function createCacheStoreService({ async set(key, val, maxAge = 3600000, meta = {}) { if (unusable()) return null; + // maxAge 0 (or less) means "do not cache". keyv and cache-manager read a + // ttl of 0 as "no expiry", so passing it through stored the entry forever. + if (typeof maxAge === 'number' && maxAge <= 0) return null; + try { return provider.set(`${keysPrefix}${key}`, serialize(val, meta), maxAge); } catch (error) { diff --git a/shared/tests/max-age-zero.test.js b/shared/tests/max-age-zero.test.js new file mode 100644 index 0000000..4a369a4 --- /dev/null +++ b/shared/tests/max-age-zero.test.js @@ -0,0 +1,66 @@ +"use strict"; + +/** + * `maxAge: 0` means "do not cache". + * + * keyv and cache-manager read a ttl of 0 as "no expiry", so a route configured + * with maxAge 0 was stored forever - while, with cacheControl enabled, the same + * response told clients `max-age=0`. A route that asks for no lifetime gets + * no entry. + */ + +const { setup, teardown, agent } = require("./helpers/strapi"); + +jest.setTimeout(60000); + +process.env.STRAPI_DISABLE_UPDATE_NOTIFICATION = true; +process.env.STRAPI_HIDE_STARTUP_MESSAGE = true; +process.env.STRAPI_TELEMETRY_DISABLED = true; + +const ROUTE = "/api/categories/probe/inherits"; + +describe("maxAge: 0", () => { + beforeAll(async () => { + process.env.KEYS_PREFIX = undefined; + // category's maxAge, which its listed /probe/inherits route takes. + process.env.CATEGORY_MAX_AGE = "0"; + await setup(); + }); + afterAll(async () => { + delete process.env.CATEGORY_MAX_AGE; + await teardown(); + }); + + const store = () => strapi.plugin("rest-cache").service("cacheStore"); + + beforeEach(() => store().reset()); + + it("does not serve a route with maxAge 0 from the cache", async () => { + const first = await agent().get(ROUTE); + const second = await agent().get(ROUTE); + + expect(first.status).toBe(200); + expect(first.get("x-cache")).toBe("MISS"); + expect(second.get("x-cache")).toBe("MISS"); + }); + + it("stores nothing for it", async () => { + await agent().get(ROUTE); + + const keys = (await store().keys()).filter((k) => k.startsWith(ROUTE)); + expect(keys).toEqual([]); + }); + + it("does not store through cacheStore.set with maxAge 0 either", async () => { + await store().set("/api/direct-zero?", { a: 1 }, 0); + + expect(await store().get("/api/direct-zero?")).toBeNull(); + }); + + it("still caches routes with a positive maxAge", async () => { + await agent().get("/api/articles"); + const second = await agent().get("/api/articles"); + + expect(second.get("x-cache")).toBe("HIT"); + }); +});