fix(routes): report an upstream routing outage as unavailable

When the OSRM routing service failed (a 5xx, a 403 block, a 404), answered
with a body that was not JSON, or did not answer at all (timeout, network
error), /api/route returned HTTP 200 with 'no route found' or 'route proxy
error'. plan_route turned that into invalid_arguments ("No walk route from
...: no route found"), blaming the caller for an outage, and the directions
layer reported "No route found between A and B".

The proxy now answers all of these with 502 {ok:false, error:'routing
service unavailable'}: a rejected upstream fetch, any failure status but
400, a non-JSON content type, and a body read that fails or does not parse.
An exception in the proxy's own code after that point is still a 200 'route
proxy error'. createRouteService throws for a 5xx before reading the body's
ok:false, so plan_route reports unavailable, and the directions source
treats any 5xx as "Routing unavailable (HTTP n)", also when the failure
page in front of the proxy is not JSON. OSRM's own refusal (a 400 such as
NoRoute, or a 200 without a route) still reads "no route found".

The 12 s upstream timeout now starts when the request leaves rather than
before it waits for an outbound slot, so a wait in the proxy's own queue
(up to about 8 s at the 1 s spacing) is not reported as an outage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-11-02 08:42:57 -08:00
1 parent ebd8439868
commit f8795ff426
7 files changed
+223 -34

No files matched your search

+55 -17
View File
@@ -72,6 +72,17 @@ class UpstreamRateLimitError extends Error {
}
}
/**
* Thrown when the routing service failed, did not answer (timeout, network
* error, refused redirect) or did not answer with JSON.
*/
class UpstreamUnavailableError extends Error {
constructor() {
super('routing service unavailable');
this.name = 'UpstreamUnavailableError';
}
}
/**
* Run one outbound request no sooner than the shared gate allows.
* @template T
@@ -167,18 +178,26 @@ async function fetchRoute({
`${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` +
`?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
let timer;
let osrm;
try {
const upstreamRes = await throughUpstreamGate(() =>
fetchImpl(upstream, {
signal: controller.signal,
// The endpoint is configured above; a redirect is the one way out of
// it, so it is refused rather than followed.
redirect: 'error',
headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
}),
);
const upstreamRes = await throughUpstreamGate(async () => {
// The 12 s budget starts when the request leaves, not while it waits
// for an outbound slot, so a wait in our own queue is never reported
// as the routing service being unavailable.
timer = setTimeout(() => controller.abort(), 12000);
try {
return await fetchImpl(upstream, {
signal: controller.signal,
// The endpoint is configured above; a redirect is the one way out of
// it, so it is refused rather than followed.
redirect: 'error',
headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
});
} catch {
throw new UpstreamUnavailableError();
}
});
if (upstreamRes.status === 429) {
await cancelBody(upstreamRes);
const retryAfter = Number(upstreamRes.headers.get('retry-after'));
@@ -186,20 +205,31 @@ async function fetchRoute({
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5,
);
}
if (!upstreamRes.ok) {
// OSRM answers 400 for a request it cannot route (NoRoute, NoSegment);
// any other failure status (a 5xx, a 403 block, a 404) is the service's.
if (upstreamRes.status === 400) {
await cancelBody(upstreamRes);
return { payload: null, error: 'no route found' };
}
if (!upstreamRes.ok) {
await cancelBody(upstreamRes);
throw new UpstreamUnavailableError();
}
const ctype = upstreamRes.headers.get('content-type') || '';
if (!ctype.includes('json')) {
await cancelBody(upstreamRes);
return { payload: null, error: 'no route found' };
throw new UpstreamUnavailableError();
}
// A body cut off by the timeout or the network, an oversized one and one
// that is not JSON are all the service's failure; an exception after this
// point is ours and stays a "route proxy error".
try {
osrm = JSON.parse(
await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES),
);
} catch {
throw new UpstreamUnavailableError();
}
const text = await readResponseTextCapped(
upstreamRes,
ROUTE_MAX_RESPONSE_BYTES,
);
osrm = JSON.parse(text);
} finally {
clearTimeout(timer);
}
@@ -238,6 +268,10 @@ export function installRouteMiddleware(
});
res.end(JSON.stringify({ ok: false, error: msg }));
};
const unavailable = (msg) => {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: msg }));
};
try {
if (!_routeRateLimiter(clientKey(req))) {
rateLimited('rate limited', 5);
@@ -350,6 +384,10 @@ export function installRouteMiddleware(
upstreamError.retryAfterSec,
);
}
if (upstreamError instanceof UpstreamUnavailableError) {
// Same reason: an outage says nothing about whether a route exists.
return unavailable(upstreamError.message);
}
throw upstreamError;
}
if (error || !payload) return fail(error || 'no route found');