Files
DIYgod 17b9b9ff5f fix(cache): clamp Workers KV expirationTtl to its 60s minimum (#23407)
Cloudflare Workers KV rejects put() calls whose expirationTtl is below
60 seconds. Some callers use shorter TTLs that work on the memory and
Redis backends, e.g. the 1-second lock release in the Twitter web API
utils and the data258 route, so on Workers those calls throw
"Invalid expiration_ttl of 1".

Clamp the TTL passed to KV in the kv cache module and in the worker
globalCache.set path. The requested TTL is still stored in the cacheTtl
sidecar key.

Based on #23400 and #23403 by @EngDawood.

Co-authored-by: Dawood Saleh <52585641+EngDawood@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:48:18 +08:00

80 lines
2.7 KiB
TypeScript

// Cloudflare Workers KV cache module
import type { KVNamespace } from '@cloudflare/workers-types';
import { config } from '@/config';
import type CacheModule from './base';
import { stringify } from './base';
let kvNamespace: KVNamespace | null = null;
const status = { available: false };
const getCacheTtlKey = (key: string) => {
if (key.startsWith('rsshub:cacheTtl:')) {
throw new Error('"rsshub:cacheTtl:" prefix is reserved for the internal usage, please change your cache key');
}
return `rsshub:cacheTtl:${key}`;
};
export const setKVNamespace = (kv: KVNamespace) => {
kvNamespace = kv;
status.available = true;
};
export const getKVNamespace = () => kvNamespace;
// Workers KV rejects expirationTtl values below 60 seconds
const KV_MIN_TTL = 60;
export const toKVTtl = (ttl: number) => Math.max(ttl, KV_MIN_TTL);
export default {
init: () => {
// KV namespace is set via setKVNamespace from Worker env binding
},
get: async (key: string, refresh = true) => {
if (key && status.available && kvNamespace) {
const cacheTtlKey = getCacheTtlKey(key);
const [value, cacheTtl] = await Promise.all([kvNamespace.get(key), kvNamespace.get(cacheTtlKey)]);
if (value && refresh) {
const ttl = cacheTtl ? Number(cacheTtl) : config.cache.contentExpire;
// Refresh TTL by re-setting the value
// KV doesn't have a native expire refresh, so we need to re-put
// Use waitUntil pattern in production for non-blocking refresh
await Promise.all([kvNamespace.put(key, value, { expirationTtl: toKVTtl(ttl) }), cacheTtl ? kvNamespace.put(cacheTtlKey, cacheTtl, { expirationTtl: toKVTtl(ttl) }) : Promise.resolve()]);
}
return value || '';
}
return null;
},
has: async (key: string) => {
if (key && status.available && kvNamespace) {
const value = await kvNamespace.get(key);
return value !== null;
}
return false;
},
set: async <T>(key: string, value?: string | T, maxAge = config.cache.contentExpire) => {
if (!status.available || !kvNamespace) {
return;
}
const stored = stringify(value);
if (!key) {
return;
}
const promises: Array<Promise<void>> = [kvNamespace.put(key, stored, { expirationTtl: toKVTtl(maxAge) })];
if (maxAge !== config.cache.contentExpire) {
// Store the cache ttl if it is not the default value
promises.push(kvNamespace.put(getCacheTtlKey(key), String(maxAge), { expirationTtl: toKVTtl(maxAge) }));
}
await Promise.all(promises);
},
clients: {},
status,
} satisfies CacheModule;