perf: deduplicate concurrent API reads
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
_clearApiClientInflightForTests,
|
||||||
|
fetchJson,
|
||||||
|
setTokenGetter,
|
||||||
|
} from './api-client.js';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deduplicates concurrent GET requests for the same URL and token', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
let resolveFetch: ((response: Response) => void) | undefined;
|
||||||
|
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
|
||||||
|
calls += 1;
|
||||||
|
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token');
|
||||||
|
return new Promise<Response>(resolve => { resolveFetch = resolve; });
|
||||||
|
}) as typeof fetch;
|
||||||
|
setTokenGetter(() => 'test-token');
|
||||||
|
_clearApiClientInflightForTests();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = fetchJson<{ value: number }>('/api/test');
|
||||||
|
const second = fetchJson<{ value: number }>('/api/test');
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
resolveFetch?.(jsonResponse({ value: 42 }));
|
||||||
|
assert.deepEqual(await first, { value: 42 });
|
||||||
|
assert.deepEqual(await second, { value: 42 });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
setTokenGetter(() => null);
|
||||||
|
_clearApiClientInflightForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not retain a failed GET request', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('network down');
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}) as typeof fetch;
|
||||||
|
setTokenGetter(() => null);
|
||||||
|
_clearApiClientInflightForTests();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(fetchJson('/api/retry'), /network down/);
|
||||||
|
assert.deepEqual(await fetchJson('/api/retry'), { ok: true });
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
_clearApiClientInflightForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
+24
-2
@@ -1,13 +1,13 @@
|
|||||||
/** 全局认证 fetch 客户端 */
|
/** 全局认证 fetch 客户端 */
|
||||||
|
|
||||||
let tokenGetter: () => string | null = () => null;
|
let tokenGetter: () => string | null = () => null;
|
||||||
|
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
export function setTokenGetter(fn: () => string | null) {
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
tokenGetter = fn;
|
tokenGetter = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
|
||||||
const token = tokenGetter();
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -22,3 +22,25 @@ export async function fetchJson<T>(url: string, options?: RequestInit): Promise<
|
|||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = tokenGetter();
|
||||||
|
const method = (options?.method || 'GET').toUpperCase();
|
||||||
|
const canDedupe = method === 'GET' && options?.body == null && options?.signal == null;
|
||||||
|
if (!canDedupe) return executeJsonRequest<T>(url, options, token);
|
||||||
|
|
||||||
|
const key = `${token || 'anonymous'}:${url}`;
|
||||||
|
const existing = inflightGetRequests.get(key) as Promise<T> | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const request = executeJsonRequest<T>(url, options, token)
|
||||||
|
.finally(() => {
|
||||||
|
if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key);
|
||||||
|
});
|
||||||
|
inflightGetRequests.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _clearApiClientInflightForTests(): void {
|
||||||
|
inflightGetRequests.clear();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user