diff --git a/src/lib/api-timeout.test.ts b/src/lib/api-timeout.test.ts new file mode 100644 index 0000000..da28056 --- /dev/null +++ b/src/lib/api-timeout.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiFetch, ApiError, NetworkError } from './api'; + +/** A fetch that never resolves on its own, only rejects when its signal aborts. */ +function hangingFetch() { + return vi.fn((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new DOMException('Aborted', 'AbortError'))); + }), + ) as unknown as typeof fetch; +} + +describe('apiFetch timeout & cancellation (#54)', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('fails with a friendly NetworkError when the backend stalls past the timeout', async () => { + global.fetch = hangingFetch(); + const err = await apiFetch('/events', { auth: false, timeoutMs: 20 }).catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(0); + expect(err.message).toMatch(/took too long/); + }); + + it('wraps a network failure in a NetworkError', async () => { + global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as unknown as typeof fetch; + const err = await apiFetch('/events', { auth: false }).catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + expect(err.message).toMatch(/Could not reach the server/); + }); + + it('rethrows the AbortError when the caller cancels', async () => { + global.fetch = hangingFetch(); + const controller = new AbortController(); + const pending = apiFetch('/events', { auth: false, signal: controller.signal }); + controller.abort(); + const err = await pending.catch((e) => e); + expect(err).not.toBeInstanceOf(NetworkError); + expect(err.name).toBe('AbortError'); + }); + + it('passes an AbortSignal to fetch by default', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({}) }); + global.fetch = fetchMock as unknown as typeof fetch; + await apiFetch('/events', { auth: false }); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 65af52c..f6d35c3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,6 +11,21 @@ export class ApiError extends Error { } } +/** + * The request never got a usable response: it timed out or the network + * failed. Extends ApiError (status 0) so existing `instanceof ApiError` + * handling, e.g. actionErrorMessage, surfaces its friendly message. + */ +export class NetworkError extends ApiError { + constructor(message: string) { + super(message, 0); + this.name = 'NetworkError'; + } +} + +/** Default per-request timeout; override with `RequestOptions.timeoutMs`. */ +export const DEFAULT_TIMEOUT_MS = 15_000; + export function getToken(): string | null { if (typeof window === 'undefined') return null; return window.localStorage.getItem(TOKEN_KEY); @@ -28,11 +43,19 @@ interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; auth?: boolean; + /** Caller-controlled cancellation (e.g. on unmount). Aborting rethrows the original AbortError. */ + signal?: AbortSignal; + /** Milliseconds before the request fails with a NetworkError. Defaults to DEFAULT_TIMEOUT_MS; 0 disables. */ + timeoutMs?: number; +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'); } /** Backend BigInt fields (prices, chain ids) arrive as JSON strings/numbers depending on Nest's serializer; kept as strings end-to-end. */ export async function apiFetch(path: string, options: RequestOptions = {}): Promise { - const { method = 'GET', body, auth = true } = options; + const { method = 'GET', body, auth = true, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = options; const headers: Record = { 'Content-Type': 'application/json' }; if (auth) { @@ -40,11 +63,28 @@ export async function apiFetch(path: string, options: RequestOptions = {}): P if (token) headers.Authorization = `Bearer ${token}`; } - const res = await fetch(`${API_URL}${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + // Combine the caller's signal with the timeout so a stalled backend can't + // leave the UI in a loading state forever (#54). + const timeoutSignal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined; + const signals = [signal, timeoutSignal].filter((s): s is AbortSignal => s !== undefined); + const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]; + + let res: Response; + try { + res = await fetch(`${API_URL}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: combined, + }); + } catch (err) { + // A deliberate cancel by the caller is not an error to show the user. + if (signal?.aborted) throw err; + if (timeoutSignal?.aborted || isAbortError(err)) { + throw new NetworkError('The server took too long to respond. Please try again.'); + } + throw new NetworkError('Could not reach the server. Check your connection and try again.'); + } if (!res.ok) { const payload = await res.json().catch(() => null);