From 8a39446acce72635c1589edc54dd1de655c4cf81 Mon Sep 17 00:00:00 2001 From: Niffy03 Date: Sat, 29 Aug 2026 21:35:05 +0100 Subject: [PATCH] feat: implement concurrent GET request deduplication in apiClient using a cache-based coalescing strategy. --- src/lib/api.ts | 34 +++++-- src/lib/api/__tests__/api.dedupe.test.ts | 121 +++++++++++++++++++++++ src/lib/api/__tests__/dedupe.test.ts | 21 ++++ src/lib/api/dedupe.ts | 5 +- 4 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/lib/api/__tests__/api.dedupe.test.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index 23c2f048..0001004a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,6 +15,8 @@ import { import { logContextStorage } from './logging/context'; import { tokenManager } from '@/lib/auth/tokenManager'; +import { dedupe, buildDedupeKey } from './api/dedupe'; + export type { ErrorInfo }; // --------------------------------------------------------------------------- @@ -45,6 +47,7 @@ export interface RequestConfig extends RequestInit { timeout?: number; schema?: z.ZodSchema; useCache?: boolean; + dedupe?: boolean; _bypassCacheRead?: boolean; _authRetried?: boolean; ttl?: number; @@ -122,8 +125,16 @@ class ApiClientImpl { } invalidateCache(url?: string) { - if (url) this.cache.delete(url); - else this.cache.clear(); + if (url) { + this.cache.delete(url); + for (const key of this.cache.keys()) { + if (key.startsWith(`${url}:`)) { + this.cache.delete(key); + } + } + } else { + this.cache.clear(); + } } addRequestInterceptor(interceptor: RequestInterceptor) { @@ -237,11 +248,20 @@ class ApiClientImpl { * GET request */ async get(url: string, options?: Omit): Promise { - return this.requestWithRetry({ - ...options, - url, - method: 'GET', - }); + const shouldDedupe = options?.dedupe !== false; + const requestFn = () => + this.requestWithRetry({ + ...options, + url, + method: 'GET', + }); + + if (shouldDedupe) { + const key = buildDedupeKey('GET', url); + return dedupe(key, requestFn); + } + + return requestFn(); } /** diff --git a/src/lib/api/__tests__/api.dedupe.test.ts b/src/lib/api/__tests__/api.dedupe.test.ts new file mode 100644 index 00000000..12755eab --- /dev/null +++ b/src/lib/api/__tests__/api.dedupe.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { apiClient } from '@/lib/api'; +import { clearDedupeCache } from '@/lib/api/dedupe'; + +describe('apiClient duplicate in-flight GET request coalescing', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + clearDedupeCache(); + apiClient.invalidateCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearDedupeCache(); + apiClient.invalidateCache(); + }); + + it('coalesces concurrent identical GET requests onto a single network call and fans out results', async () => { + let resolveFetch!: (value: any) => void; + const fetchPromise = new Promise((res) => { + resolveFetch = res; + }); + + (fetch as ReturnType).mockReturnValue(fetchPromise); + + const p1 = apiClient.get('/api/v1/users/profile'); + const p2 = apiClient.get('/api/v1/users/profile'); + const p3 = apiClient.get('/api/v1/users/profile'); + + // Yield to the microtask queue so the async requestWithRetry reaches fetch + await Promise.resolve(); + + expect(fetch).toHaveBeenCalledTimes(1); + + resolveFetch({ + ok: true, + json: async () => ({ id: 'u1', name: 'Alice' }), + }); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1).toEqual({ id: 'u1', name: 'Alice' }); + expect(r2).toEqual({ id: 'u1', name: 'Alice' }); + expect(r3).toEqual({ id: 'u1', name: 'Alice' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('fans out errors to all concurrent callers if the in-flight GET request fails', async () => { + let rejectFetch!: (reason?: unknown) => void; + const fetchPromise = new Promise((_, rej) => { + rejectFetch = rej; + }); + + (fetch as ReturnType).mockReturnValue(fetchPromise); + + const p1 = apiClient.get('/api/v1/failing-resource'); + const p2 = apiClient.get('/api/v1/failing-resource'); + + await Promise.resolve(); + + expect(fetch).toHaveBeenCalledTimes(1); + + rejectFetch(new Error('Network disconnected')); + + await expect(p1).rejects.toThrow(); + await expect(p2).rejects.toThrow(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce requests with different URLs', async () => { + (fetch as ReturnType).mockImplementation(async (url: string) => { + return { + ok: true, + json: async () => ({ endpoint: url }), + }; + }); + + const p1 = apiClient.get('/api/v1/resource-a'); + const p2 = apiClient.get('/api/v1/resource-b'); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toEqual({ endpoint: '/api/v1/resource-a' }); + expect(r2).toEqual({ endpoint: '/api/v1/resource-b' }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('allows subsequent GET requests after in-flight completes', async () => { + (fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ call: 1 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ call: 2 }), + }); + + const r1 = await apiClient.get('/api/v1/sequential'); + expect(r1).toEqual({ call: 1 }); + + const r2 = await apiClient.get('/api/v1/sequential'); + expect(r2).toEqual({ call: 2 }); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('allows opting out of deduplication when dedupe: false is provided', async () => { + (fetch as ReturnType).mockImplementation(async () => ({ + ok: true, + json: async () => ({ time: Date.now() }), + })); + + const p1 = apiClient.get('/api/v1/no-dedupe', { dedupe: false }); + const p2 = apiClient.get('/api/v1/no-dedupe', { dedupe: false }); + + await Promise.all([p1, p2]); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/api/__tests__/dedupe.test.ts b/src/lib/api/__tests__/dedupe.test.ts index 4ea72517..4eae4908 100644 --- a/src/lib/api/__tests__/dedupe.test.ts +++ b/src/lib/api/__tests__/dedupe.test.ts @@ -26,6 +26,27 @@ describe('dedupe (basic behavior)', () => { expect(fn).toHaveBeenCalledTimes(1); }); + it('fans out results to 3 or more concurrent callers', async () => { + const d = deferred<{ count: number }>(); + const fn = vi.fn().mockReturnValue(d.promise); + + const p1 = dedupe('fanout-key', fn); + const p2 = dedupe('fanout-key', fn); + const p3 = dedupe('fanout-key', fn); + const p4 = dedupe('fanout-key', fn); + + d.resolve({ count: 42 }); + + const results = await Promise.all([p1, p2, p3, p4]); + expect(results).toEqual([ + { count: 42 }, + { count: 42 }, + { count: 42 }, + { count: 42 }, + ]); + expect(fn).toHaveBeenCalledTimes(1); + }); + it('allows different keys to proceed independently', async () => { const d1 = deferred(); const d2 = deferred(); diff --git a/src/lib/api/dedupe.ts b/src/lib/api/dedupe.ts index 3a29c802..b208d852 100644 --- a/src/lib/api/dedupe.ts +++ b/src/lib/api/dedupe.ts @@ -72,14 +72,13 @@ export function dedupe(key: string, fn: () => Promise): Promise { fn() .then((result) => { clearTimeout(entry.timer); + cache.delete(key); resolve(result); }) .catch((err) => { clearTimeout(entry.timer); - reject(err); - }) - .finally(() => { cache.delete(key); + reject(err); }); return promise;