diff --git a/client/api.ts b/client/api.ts index ff6a1b3..c296e4e 100644 --- a/client/api.ts +++ b/client/api.ts @@ -48,6 +48,12 @@ const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; // Parse a response body without assuming it is JSON. Returns null when the // body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). +// A hung request (server accepted the connection but never responds) would +// otherwise spin the UI forever. 120s is generous enough for the slowest +// legitimate op (large import / bank sync / backup) on a self-hosted LAN while +// still bounding a truly dead request so it surfaces as an error, not a freeze. +const REQUEST_TIMEOUT_MS = 120_000; + async function parseJsonSafe(res: Response): Promise { if (res.status === 204) return null; const text = await res.text(); @@ -66,7 +72,20 @@ async function _fetch(method: string, path: string, body?: unknown, } } if (body !== undefined) opts.body = JSON.stringify(body); - const res = await fetch('/api' + path, opts); + let res: Response; + try { + res = await fetch('/api' + path, { ...opts, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + } catch (e) { + // Timeout (AbortSignal.timeout → TimeoutError) or network failure (TypeError) + // — both would otherwise leave the UI hung or show a raw "Failed to fetch". + const timedOut = (e as Error)?.name === 'TimeoutError'; + const err = new Error(timedOut + ? 'The request timed out — the server may be busy or unreachable. Please try again.' + : 'Network error — could not reach the server. Check your connection and try again.') as ApiError; + err.status = timedOut ? 408 : 0; + err.code = timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR'; + throw err; + } const data = await parseJsonSafe(res); if (!res.ok) { // Stale CSRF token (cookie rotated/expired since first fetch): refresh the diff --git a/server.js b/server.js index d8f5001..2328e6f 100644 --- a/server.js +++ b/server.js @@ -180,6 +180,9 @@ app.get('*', (req, res) => { // ── Global error handler ────────────────────────────────────────────────────── // Never expose stack traces, internal paths, or raw error objects in responses. app.use((err, req, res, next) => { + // If a response was already (partially) sent, we can't write a new body/status — + // delegate to Express's default handler, which closes the connection cleanly. + if (res.headersSent) return next(err); recordError('Express', err); console.error('[error]', err.message || String(err));