From d1764b3d6607e2df6e0f419f0896aab0fd89c6ca Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 07:18:49 +0800 Subject: [PATCH] fix(web): bound stalled route queries --- .../apps/web/src/api/client.test.ts | 50 +++++++++++++++-- .../apps/web/src/api/client.ts | 53 ++++++++++++++++--- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 23ab42c9..1c12b8fc 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -1,9 +1,10 @@ import { afterEach, expect, test, vi } from 'vitest'; -import { api } from './client'; +import { API_QUERY_TIMEOUT_MS, api } from './client'; import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session'; afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); window.sessionStorage.clear(); }); @@ -146,14 +147,14 @@ test('monitor viewport requests forward AbortSignal so obsolete pans can be canc const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { points: [], clusters: [] } }) } as Response); const controller = new AbortController(); await api.monitorMap(new URLSearchParams({ zoom: '13', bounds: '113,22,114,23' }), controller.signal); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: expect.any(AbortSignal) }); }); test('monitor workspace aggregates the map screen behind one abortable request', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { summary: {}, vehicles: { items: [] }, map: { points: [], clusters: [] } } }) } as Response); const controller = new AbortController(); await api.monitorWorkspace(new URLSearchParams({ zoom: '13', railLimit: '200' }), controller.signal); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/workspace?zoom=13&railLimit=200', { signal: controller.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/workspace?zoom=13&railLimit=200', { signal: expect.any(AbortSignal) }); }); test('high-volume history, track, and mileage requests forward AbortSignal', async () => { @@ -167,7 +168,7 @@ test('high-volume history, track, and mileage requests forward AbortSignal', asy await api.dailyMileage(params, controller.signal); await api.mileageStatistics(params, controller.signal); - for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal }); + for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal); }); test('route-scoped platform reads forward AbortSignal on navigation cleanup', async () => { @@ -195,7 +196,46 @@ test('route-scoped platform reads forward AbortSignal on navigation cleanup', as await api.sourceReadiness(signal); expect(fetchMock).toHaveBeenCalledTimes(18); - for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBe(signal); + for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal); +}); + +test('turns a stalled route query into a bounded retryable timeout', async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + let transportSignal: AbortSignal | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementation((_path, init) => { + transportSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + transportSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }); + }); + + const timedOut = expect(api.monitorSummary(new URLSearchParams(), controller.signal)).rejects.toMatchObject({ + name: 'ApiRequestTimeoutError', + message: expect.stringContaining('15 秒') + }); + await vi.advanceTimersByTimeAsync(API_QUERY_TIMEOUT_MS); + + await timedOut; + expect(transportSignal?.aborted).toBe(true); + expect(controller.signal.aborted).toBe(false); +}); + +test('preserves navigation cancellation instead of reporting it as a timeout', async () => { + const controller = new AbortController(); + let transportSignal: AbortSignal | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementation((_path, init) => { + transportSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + transportSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }); + }); + + const cancelled = expect(api.monitorSummary(new URLSearchParams(), controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + controller.abort(); + + await cancelled; + expect(transportSignal?.aborted).toBe(true); }); test('rawFramesQuery posts structured JSON instead of URL query strings', async () => { diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index a51e59ed..0c173edb 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -85,16 +85,57 @@ type ApiErrorEnvelope = { traceId?: string; }; +export const API_QUERY_TIMEOUT_MS = 15_000; + +function queryTimeout(init: RequestInit | undefined) { + const upstream = init?.signal; + if (!upstream) return undefined; + const controller = new AbortController(); + let timedOut = false; + const abortFromUpstream = () => controller.abort(upstream.reason); + if (upstream.aborted) abortFromUpstream(); + else upstream.addEventListener('abort', abortFromUpstream, { once: true }); + const timer = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, API_QUERY_TIMEOUT_MS); + return { + init: { ...init, signal: controller.signal }, + timedOut: () => timedOut, + cleanup: () => { + window.clearTimeout(timer); + upstream.removeEventListener('abort', abortFromUpstream); + } + }; +} + +function requestTimeoutError() { + const error = new Error(`请求超过 ${API_QUERY_TIMEOUT_MS / 1_000} 秒仍未完成,请检查网络后重试`); + error.name = 'ApiRequestTimeoutError'; + return error; +} + async function request(path: string, init?: RequestInit): Promise { const token = getAccessToken(); const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init; - const response = await fetch(path, requestInit); - if (!response.ok) { - if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession(token); - throw new Error(await responseErrorMessage(response)); + const timeout = queryTimeout(requestInit); + try { + const response = await fetch(path, timeout?.init ?? requestInit); + if (!response.ok) { + if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession(token); + throw new Error(await responseErrorMessage(response)); + } + const envelope = (await response.json()) as ApiEnvelope; + if (timeout?.timedOut()) throw requestTimeoutError(); + return envelope.data; + } catch (error) { + if (timeout?.timedOut() && (!(error instanceof Error) || error.name !== 'ApiRequestTimeoutError')) { + throw requestTimeoutError(); + } + throw error; + } finally { + timeout?.cleanup(); } - const envelope = (await response.json()) as ApiEnvelope; - return envelope.data; } function withAuthorization(headers: HeadersInit | undefined, token: string) {