fix(planner/brouter): add 30s timeout to outbound fetch

Mirrors journal PR #5. `fetchSegment()` and `computeSegmentGpx()` in
lib/brouter.ts called `fetch()` with no AbortSignal — a hung or slow
BRouter would stall the request handler indefinitely.

New `lib/http.server.ts::fetchWithTimeout()` (same as the journal's
helper) wraps fetch with `AbortSignal.timeout(30_000)`, composable
with a caller-supplied signal via `AbortSignal.any()`.

Tests: lib/http.server.test.ts (2 cases — timeout abort, happy path).
This commit is contained in:
Ullrich Schäfer 2026-05-25 23:53:19 +02:00
parent e647a29633
commit d70d6ee8a8
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
3 changed files with 46 additions and 2 deletions

View file

@ -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<Record<string, unknown>> {
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) {

View file

@ -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);
});
});

View file

@ -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<Response> {
const signal = init.signal
? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)])
: AbortSignal.timeout(timeoutMs);
return fetch(input, { ...init, signal });
}