Bug Description
SqliteCacheStore returns early when the first cached row for a URL/method is expired
Reproducible By
import { strictEqual } from "node:assert";
import { once } from "node:events";
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
import {
Agent,
request,
setGlobalDispatcher,
getGlobalDispatcher,
interceptors,
cacheStores,
} from "undici";
if (!cacheStores.SqliteCacheStore) {
throw new Error("SqliteCacheStore is not available in this Node.js build");
}
let requestCount = 0;
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestCount++;
const ae = req.headers["accept-encoding"];
res.setHeader("Vary", "Accept-Encoding");
if (ae === "br") {
// Keep the br variant valid so it should still be returned from cache.
res.setHeader("Cache-Control", "max-age=60");
res.end(`br from origin #${requestCount}`);
return;
}
if (ae === "gzip") {
// Make the gzip variant expire sooner than the br variant.
res.setHeader("Cache-Control", "max-age=1");
res.end(`gzip from origin #${requestCount}`);
return;
}
res.statusCode = 400;
res.end(`unexpected accept-encoding: ${ae}`);
});
await once(server.listen(0), "listening");
const origin = `http://localhost:${server.address().port}`;
const previousDispatcher = getGlobalDispatcher();
const dispatcher = new Agent().compose(
interceptors.cache({
store: new cacheStores.SqliteCacheStore(),
methods: ["GET"],
}),
);
setGlobalDispatcher(dispatcher);
try {
{
const res = await request(origin, {
method: "GET",
headers: { "accept-encoding": "br" },
});
const body = await res.body.text();
strictEqual(body, "br from origin #1");
strictEqual(requestCount, 1);
}
{
const res = await request(origin, {
method: "GET",
headers: { "accept-encoding": "gzip" },
});
const body = await res.body.text();
strictEqual(body, "gzip from origin #2");
strictEqual(requestCount, 2);
}
// SqliteCacheStore orders candidates by deleteAt ASC, so wait until the gzip variant is actually expired.
await sleep(2500);
{
const res = await request(origin, {
method: "GET",
headers: { "accept-encoding": "br" },
});
const body = await res.body.text();
console.log({ body, requestCount });
}
} finally {
setGlobalDispatcher(previousDispatcher);
await dispatcher.close();
await new Promise((resolve) => server.close(resolve));
}
Expected Behavior
{ body: 'br from origin #1', requestCount: 2 }
SqliteCacheStore#get() should ignore expired rows while scanning candidate entries and continue searching for another matching row for the same URL/method. If a later Vary variant is still valid and matches the request headers, it should be returned.
Logs & Screenshots
{ body: 'br from origin #3', requestCount: 3 }
Environment
macOS 26.4.1
Node v24.15.0
undici v8.1.0
Bug Description
SqliteCacheStore returns early when the first cached row for a URL/method is expired
Reproducible By
Expected Behavior
{ body: 'br from origin #1', requestCount: 2 }SqliteCacheStore#get()should ignore expired rows while scanning candidate entries and continue searching for another matching row for the same URL/method. If a laterVaryvariant is still valid and matches the request headers, it should be returned.Logs & Screenshots
{ body: 'br from origin #3', requestCount: 3 }Environment
macOS 26.4.1
Node v24.15.0
undici v8.1.0