From 4ccf060a3ee96a27c4e5cc3d6b2ed05ca83282a4 Mon Sep 17 00:00:00 2001 From: Mo Elzubeir Date: Fri, 29 May 2026 14:39:04 -0500 Subject: [PATCH] fix: avoid caching non-positive TTL entries --- sdks/javascript/docs/CACHING_AND_RETRIES.md | 2 +- sdks/javascript/src/cache.ts | 6 +++++- sdks/javascript/test/client.test.ts | 23 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sdks/javascript/docs/CACHING_AND_RETRIES.md b/sdks/javascript/docs/CACHING_AND_RETRIES.md index 9299fb7..19cae43 100644 --- a/sdks/javascript/docs/CACHING_AND_RETRIES.md +++ b/sdks/javascript/docs/CACHING_AND_RETRIES.md @@ -48,7 +48,7 @@ interface Cache { } ``` -The cache key is the full request URL. `ttlMs` is in milliseconds and comes from `cacheTtlMs` or per-request `revalidateSeconds`. +The cache key is the full request URL. `ttlMs` is in milliseconds and comes from `cacheTtlMs` or per-request `revalidateSeconds`. A non-positive `ttlMs` means "do not cache"; built-in caches delete/skip the entry rather than storing it forever. ## Redis-style cache example diff --git a/sdks/javascript/src/cache.ts b/sdks/javascript/src/cache.ts index 3ae20fd..ad3971a 100644 --- a/sdks/javascript/src/cache.ts +++ b/sdks/javascript/src/cache.ts @@ -5,7 +5,7 @@ export interface Cache { /** Return a cached value, or `undefined` on miss/expiry. */ get(key: string): Promise; - /** Store a value for the given TTL in milliseconds. */ + /** Store a value for the given TTL in milliseconds. Non-positive TTL means "do not cache". */ set(key: string, value: unknown, ttlMs: number): Promise; /** Delete one cache entry. */ delete(key: string): Promise; @@ -31,6 +31,10 @@ export class MemoryCache implements Cache { } async set(key: string, value: unknown, ttlMs: number): Promise { + if (ttlMs <= 0) { + this.map.delete(key); + return; + } this.map.set(key, { at: Date.now(), value, ttlMs }); } diff --git a/sdks/javascript/test/client.test.ts b/sdks/javascript/test/client.test.ts index 1f9966d..5eb7bb3 100644 --- a/sdks/javascript/test/client.test.ts +++ b/sdks/javascript/test/client.test.ts @@ -66,6 +66,19 @@ describe('SocialhoseClient', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('does not cache a GET request when per-request revalidateSeconds is zero', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(ok({ count: 1, next: null, previous: null, results: [] })) + .mockResolvedValueOnce(ok({ count: 2, next: null, previous: null, results: [] })); + const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 60_000 }); + + await expect(client.getMentions({ page: 1 }, { revalidateSeconds: 0 })).resolves.toMatchObject({ count: 1 }); + await expect(client.getMentions({ page: 1 }, { revalidateSeconds: 0 })).resolves.toMatchObject({ count: 2 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('retries rate limits and transient server failures', async () => { const fetchMock = vi .fn() @@ -134,6 +147,16 @@ describe('MemoryCache', () => { expect(await cache.get('k')).toBeUndefined(); }); + it('does not store values when TTL is zero or negative', async () => { + const cache = new MemoryCache(); + + await cache.set('zero', 'v', 0); + await cache.set('negative', 'v', -1); + + expect(await cache.get('zero')).toBeUndefined(); + expect(await cache.get('negative')).toBeUndefined(); + }); + it('deletes a stored entry', async () => { const cache = new MemoryCache(); await cache.set('k', 'v', 60_000);