fix(web): bound stalled route queries
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user