fix(web): bound stalled route queries

This commit is contained in:
lingniu
2026-07-16 07:18:49 +08:00
parent a0448d6615
commit d1764b3d66
2 changed files with 92 additions and 11 deletions

View File

@@ -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<Response>((_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<Response>((_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 () => {

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
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<T>;
return envelope.data;
}
function withAuthorization(headers: HeadersInit | undefined, token: string) {