diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index 42f7750..13aa65d 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -1,4 +1,5 @@ import { mergeGeoJsonSegments, type EnrichedRoute, type NoGoArea, type Waypoint } from "./route-merge"; +import { fetchWithTimeout } from "./http.server"; export { mergeGeoJsonSegments }; export type { EnrichedRoute, NoGoArea }; @@ -63,7 +64,7 @@ export class BRouterError extends Error { } async function fetchSegment(url: string): Promise> { - const response = await fetch(url, { headers: authHeaders() }); + const response = await fetchWithTimeout(url, { headers: authHeaders() }); if (!response.ok) { const body = await response.text(); throw new BRouterError(body.trim(), response.status); @@ -144,7 +145,7 @@ export async function computeSegmentGpx(request: { const nogoParam = request.noGoAreas?.length ? noGoAreasToParam(request.noGoAreas) : undefined; if (nogoParam) params.set("polygons", nogoParam); - const resp = await fetch(`${BROUTER_URL}/brouter?${params}`, { + const resp = await fetchWithTimeout(`${BROUTER_URL}/brouter?${params}`, { headers: authHeaders(), }); if (!resp.ok) { diff --git a/apps/planner/app/lib/http.server.test.ts b/apps/planner/app/lib/http.server.test.ts new file mode 100644 index 0000000..f6fe9ac --- /dev/null +++ b/apps/planner/app/lib/http.server.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { fetchWithTimeout } from "./http.server.ts"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("fetchWithTimeout (planner)", () => { + it("aborts when the upstream exceeds the timeout", async () => { + globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }) as typeof fetch; + await expect(fetchWithTimeout("https://example.test", {}, 25)).rejects.toThrow(); + }); + + it("returns the response when the call resolves in time", async () => { + globalThis.fetch = vi.fn(async () => new Response("ok", { status: 200 })) as typeof fetch; + const resp = await fetchWithTimeout("https://example.test", {}, 1000); + expect(resp.status).toBe(200); + }); +}); diff --git a/apps/planner/app/lib/http.server.ts b/apps/planner/app/lib/http.server.ts new file mode 100644 index 0000000..14e9a41 --- /dev/null +++ b/apps/planner/app/lib/http.server.ts @@ -0,0 +1,15 @@ +// Default timeout for outbound HTTP calls to third-party services +// (BRouter, Overpass, …). A hung upstream must not stall the request +// handler indefinitely. +export const DEFAULT_EXTERNAL_FETCH_TIMEOUT_MS = 30_000; + +export function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_EXTERNAL_FETCH_TIMEOUT_MS, +): Promise { + const signal = init.signal + ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) + : AbortSignal.timeout(timeoutMs); + return fetch(input, { ...init, signal }); +}