Skip to content

Commit 17b9b9f

Browse files
DIYgodEngDawoodclaude
authored
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>
1 parent 9e15f62 commit 17b9b9f

3 files changed

Lines changed: 18 additions & 5 deletions

File tree

‎lib/utils/cache/index.worker.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ describe('worker cache with KV binding', () => {
5959
expect(await env.CACHE.get('rsshub:cacheTtl:worker:default-ttl')).toBeNull();
6060
});
6161

62+
it('accepts expiry below the KV minimum of 60 seconds', async () => {
63+
await kv.set('worker:short-ttl', 'v', 1);
64+
expect(await kv.get('worker:short-ttl')).toBe('v');
65+
expect(await env.CACHE.get('rsshub:cacheTtl:worker:short-ttl')).toBe('1');
66+
67+
await cache.globalCache.set('worker:global-short-ttl', 'v', 1);
68+
expect(await cache.globalCache.get('worker:global-short-ttl')).toBe('v');
69+
});
70+
6271
it('rejects keys using the reserved ttl prefix', async () => {
6372
await expect(kv.get('rsshub:cacheTtl:foo')).rejects.toThrow('reserved');
6473
});

‎lib/utils/cache/index.worker.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { config } from '@/config';
66
import type CacheModule from './base';
77
import { stringify } from './base';
88
import http from './http';
9-
import kv, { getKVNamespace } from './kv';
9+
import kv, { getKVNamespace, toKVTtl } from './kv';
1010

1111
const cacheModule: CacheModule = config.cache.type === 'http' ? http : kv;
1212

@@ -55,7 +55,7 @@ const globalCache: GlobalCache = {
5555
}
5656
const stored = stringify(value);
5757
if (key) {
58-
await getKVNamespace()!.put(key, stored, { expirationTtl: maxAge });
58+
await getKVNamespace()!.put(key, stored, { expirationTtl: toKVTtl(maxAge) });
5959
}
6060
},
6161
claim: async (key, maxAge) => {

‎lib/utils/cache/kv.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export const setKVNamespace = (kv: KVNamespace) => {
2525

2626
export const getKVNamespace = () => kvNamespace;
2727

28+
// Workers KV rejects expirationTtl values below 60 seconds
29+
const KV_MIN_TTL = 60;
30+
export const toKVTtl = (ttl: number) => Math.max(ttl, KV_MIN_TTL);
31+
2832
export default {
2933
init: () => {
3034
// KV namespace is set via setKVNamespace from Worker env binding
@@ -39,7 +43,7 @@ export default {
3943
// Refresh TTL by re-setting the value
4044
// KV doesn't have a native expire refresh, so we need to re-put
4145
// Use waitUntil pattern in production for non-blocking refresh
42-
await Promise.all([kvNamespace.put(key, value, { expirationTtl: ttl }), cacheTtl ? kvNamespace.put(cacheTtlKey, cacheTtl, { expirationTtl: ttl }) : Promise.resolve()]);
46+
await Promise.all([kvNamespace.put(key, value, { expirationTtl: toKVTtl(ttl) }), cacheTtl ? kvNamespace.put(cacheTtlKey, cacheTtl, { expirationTtl: toKVTtl(ttl) }) : Promise.resolve()]);
4347
}
4448
return value || '';
4549
}
@@ -61,11 +65,11 @@ export default {
6165
return;
6266
}
6367

64-
const promises: Array<Promise<void>> = [kvNamespace.put(key, stored, { expirationTtl: maxAge })];
68+
const promises: Array<Promise<void>> = [kvNamespace.put(key, stored, { expirationTtl: toKVTtl(maxAge) })];
6569

6670
if (maxAge !== config.cache.contentExpire) {
6771
// Store the cache ttl if it is not the default value
68-
promises.push(kvNamespace.put(getCacheTtlKey(key), String(maxAge), { expirationTtl: maxAge }));
72+
promises.push(kvNamespace.put(getCacheTtlKey(key), String(maxAge), { expirationTtl: toKVTtl(maxAge) }));
6973
}
7074

7175
await Promise.all(promises);

0 commit comments

Comments
 (0)