Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -45,6 +47,7 @@ export interface RequestConfig extends RequestInit {
timeout?: number;
schema?: z.ZodSchema;
useCache?: boolean;
dedupe?: boolean;
_bypassCacheRead?: boolean;
_authRetried?: boolean;
ttl?: number;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -237,11 +248,20 @@ class ApiClientImpl {
* GET request
*/
async get<T>(url: string, options?: Omit<RequestConfig, 'url' | 'method'>): Promise<T> {
return this.requestWithRetry<T>({
...options,
url,
method: 'GET',
});
const shouldDedupe = options?.dedupe !== false;
const requestFn = () =>
this.requestWithRetry<T>({
...options,
url,
method: 'GET',
});

if (shouldDedupe) {
const key = buildDedupeKey('GET', url);
return dedupe<T>(key, requestFn);
}

return requestFn();
}

/**
Expand Down
121 changes: 121 additions & 0 deletions src/lib/api/__tests__/api.dedupe.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).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);
});
});
21 changes: 21 additions & 0 deletions src/lib/api/__tests__/dedupe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const d2 = deferred<string>();
Expand Down
5 changes: 2 additions & 3 deletions src/lib/api/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,13 @@ export function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
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;
Expand Down
Loading