import { reportRequestOutcome } from "../offline/connectivity"; export const DEFAULT_TIMEOUT_MS = 4_000; // GET and writes with Idempotency-Key export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000; /** A bulk POST writes up to 100 records in one server transaction (15 s allowed there). */ export const BULK_WRITE_TIMEOUT_MS = 15_000; export const UPLOAD_TIMEOUT_MS = 30_000; export const DOWNLOAD_TIMEOUT_MS = 60_000; export function timeoutFor(method: string, headers: HeadersInit | undefined): number { if (method.toUpperCase() === "GET" || new Headers(headers).has("Idempotency-Key")) { return DEFAULT_TIMEOUT_MS; } return NON_IDEMPOTENT_WRITE_TIMEOUT_MS; } function isBulkPost(input: RequestInfo | URL, method: string | undefined): boolean { if ((method ?? "GET").toUpperCase() !== "POST") return false; const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; return /\/bulk(?:[?#]|$)/.test(url); } // Statuses that must not carry a body when a Response is rebuilt. const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); // Rejects as soon as the signal aborts, so a stalled stream (which ignores the // abort on its own) still ends in the abort reason. function rejectOnAbort(promise: Promise, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const onAbort = () => reject(signal.reason); if (signal.aborted) { onAbort(); return; } signal.addEventListener("abort", onAbort, { once: true }); promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); }); } // The timeout is a TOTAL deadline: it also covers receiving the body. The body // is buffered inside the deadline and handed back as a fresh Response, so every // caller can keep using json()/text()/blob() without re-arming anything. export async function fetchWithTimeout( input: RequestInfo | URL, init: RequestInit & { timeoutMs: number }, ): Promise { const { timeoutMs, signal: callerSignal, ...rest } = init; const controller = new AbortController(); const timer = setTimeout( () => controller.abort(new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError")), timeoutMs, ); const forwardAbort = () => controller.abort(callerSignal?.reason); if (callerSignal?.aborted) { forwardAbort(); } else { callerSignal?.addEventListener("abort", forwardAbort, { once: true }); } try { const response = await rejectOnAbort( fetch(input, { ...rest, signal: controller.signal }), controller.signal, ); const body = NULL_BODY_STATUSES.has(response.status) ? null : await rejectOnAbort(response.arrayBuffer(), controller.signal); return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, }); } finally { clearTimeout(timer); callerSignal?.removeEventListener("abort", forwardAbort); } } // TypeError = fetch failed at network level; TimeoutError / AbortError = the // request was cut off. Never an ApiError (that means the server answered). export function isNetworkError(error: unknown): boolean { if (error instanceof TypeError) { return true; } return ( error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError") ); } // fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including // 502/503/504 from a proxy) counts as "reached-server"; a timeout or network // TypeError counts as "network-error". A caller-initiated AbortError // (unmount/cancel) says nothing about the network and is not reported. export async function fetchReportingOutcome( input: RequestInfo | URL, init: RequestInit & { timeoutMs: number }, ): Promise { const timeoutMs = isBulkPost(input, init.method) ? Math.max(init.timeoutMs, BULK_WRITE_TIMEOUT_MS) : init.timeoutMs; try { const response = await fetchWithTimeout(input, { ...init, timeoutMs }); reportRequestOutcome("reached-server"); return response; } catch (error) { if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) { reportRequestOutcome("network-error"); } throw error; } }