From f8795ff426d4fc2158ebb04f497a6a1e20f6ce01 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:26:15 -0700 Subject: [PATCH 1/7] 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 --- docs/CURRENT-STATE.md | 12 ++- server/providers/places/routes.js | 72 +++++++++++++---- src/layers/directions/source.js | 12 ++- src/layers/directions/source.test.mjs | 12 +++ src/tooling/placeProviders.test.mjs | 108 +++++++++++++++++++++++--- src/tools/places.js | 5 +- src/tools/queries/places.test.mjs | 36 +++++++++ 7 files changed, 223 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a8a5aa1d..178369f66 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4375,7 +4375,15 @@ service's usage policy states; past a bounded queue the answer is a 429 with a `Retry-After` rather than a queue that grows until everything times out. The upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route -found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / +found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a +404), no answer (a 12 s timeout counted from when the request leaves, not +while it waits for an outbound slot; a network error) or a body that is not +JSON answers +`502 routing service unavailable`, which `plan_route` +reports as `unavailable` and the layer as `Routing unavailable (HTTP 502)`; +only OSRM's own refusal (a 400, or a 200 without a route) reads "no route +found". +`src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at 200 maneuvers — a route with more is reported as cut off, in the row summary @@ -5694,7 +5702,7 @@ apply to `public/` assets or built-preview serving. - `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. -- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; a routing-service outage answers 502 instead. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 42e24ad32..34e64d20d 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -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'); diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index 2766f83d4..52ffc0888 100644 --- a/src/layers/directions/source.js +++ b/src/layers/directions/source.js @@ -21,7 +21,13 @@ export function createDirectionsSource({ signal, headers: { Accept: 'application/json' }, }); - const payload = await response.json(); + // A failure page in front of the proxy need not be JSON. + let payload = null; + try { + payload = await response.json(); + } catch (error) { + if (response.ok) throw error; + } signal?.throwIfAborted(); // Failures keep their English message and carry the HTTP status and the // server's refusal text, so the layer can show them in the language. @@ -39,7 +45,9 @@ export function createDirectionsSource({ { status: 429, refusal }, ); } - if (!response.ok && !payload?.error) + // A 5xx is a routing outage whatever its body says; only the proxy's + // refusals carry an error text worth showing. + if (response.status >= 500 || (!response.ok && !payload?.error)) throw Object.assign( new Error(`Routing unavailable (HTTP ${response.status})`), { status: response.status }, diff --git a/src/layers/directions/source.test.mjs b/src/layers/directions/source.test.mjs index 824b1221b..467b8ee52 100644 --- a/src/layers/directions/source.test.mjs +++ b/src/layers/directions/source.test.mjs @@ -41,3 +41,15 @@ test('route failures are surfaced without fallback requests', async () => { assert.equal(calls, 1); } }); + +test('a routing outage stays an outage when its body carries an error', async () => { + const source = createDirectionsSource({ fetchImpl: async () => + Response.json({ ok: false, error: 'routing service unavailable' }, { status: 502 }) }); + await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' }); +}); + +test('a routing outage page that is not JSON still reads as an outage', async () => { + const source = createDirectionsSource({ fetchImpl: async () => + new Response('Bad Gateway', { status: 502, headers: { 'content-type': 'text/html' } }) }); + await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' }); +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index d533b7fb9..1540d455b 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -236,23 +236,107 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be assert.equal((await request(route, url)).body.error, error); assert.equal(calls, 1); now += 600001; - t.mock.method( - globalThis, - 'fetch', - async () => new Response('offline', { status: 503 }), + // OSRM's own "no route" is a 400 with a JSON code, and stays one. + t.mock.method(globalThis, 'fetch', async () => + Response.json( + { code: 'NoRoute', message: 'Impossible route between points' }, + { status: 400 }, + ), ); - assert.deepEqual((await request(route, query)).body, { - ok: false, - error: 'no route found', - }); - t.mock.method( - globalThis, - 'fetch', - async () => new Response('x', { headers: { 'content-type': 'text/html' } }), + const noRoute = await request(route, query); + assert.equal(noRoute.statusCode, 200); + assert.deepEqual(noRoute.body, { ok: false, error: 'no route found' }); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ code: 'NoRoute', routes: [] }), ); assert.equal((await request(route, query)).body.error, 'no route found'); }); +test('a routing outage is reported as one, not as "no route found"', async (t) => { + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01'; + for (const response of [ + () => new Response('offline', { status: 503 }), + () => new Response('blocked', { status: 403 }), + () => new Response('x', { headers: { 'content-type': 'text/html' } }), + () => + new Response('', { + headers: { 'content-type': 'application/json' }, + }), + () => Promise.reject(new TypeError('fetch failed')), + () => Promise.reject(new DOMException('aborted', 'AbortError')), + ]) { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => response()); + const result = await request(route, query); + assert.equal(result.statusCode, 502); + assert.deepEqual(result.body, { + ok: false, + error: 'routing service unavailable', + }); + } +}); + +test('callers sharing one failed upstream call all hear the outage, and it is not cached', async (t) => { + _resetRouteUpstreamForTest(); + _setRouteUpstreamIntervalForTest(0); + t.after(() => _resetRouteUpstreamForTest()); + const request = install(installRouteMiddleware); + let calls = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + await gate; + return new Response('offline', { status: 503 }); + }); + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01'; + const all = [ + request(route, `${query}&steps=1`), + request(route, `${query}&steps=1`), + request(route, query), + ]; + await Promise.resolve(); + release(); + for (const result of await Promise.all(all)) { + assert.equal(result.statusCode, 502); + assert.equal(result.body.error, 'routing service unavailable'); + } + assert.equal(calls, 1); + assert.equal((await request(route, query)).statusCode, 502); + assert.equal(calls, 2, 'the next request asks the routing service again'); +}); + +test('a wait for an outbound slot does not count toward the upstream timeout', async (t) => { + _resetRouteUpstreamForTest(); + // The second request waits 13 s for its slot, longer than the 12 s budget. + _setRouteUpstreamIntervalForTest(13_000); + t.after(() => _resetRouteUpstreamForTest()); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() }); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async (_url, { signal }) => { + signal.throwIfAborted(); + return Response.json(osrmRouteWithSteps(2)); + }); + const first = request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + ); + const second = request( + '/api/route', + '?profile=foot&coords=-97,30;-97.01,30.01', + ); + assert.equal((await first).body.ok, true); + t.mock.timers.tick(13_000); + const result = await second; + assert.equal(result.statusCode, 200); + assert.equal(result.body.ok, true); +}); + /** One OSRM route with `count` maneuvers, for the step tests. */ function osrmRouteWithSteps(count) { return { diff --git a/src/tools/places.js b/src/tools/places.js index 4a715f389..8d67ec5d1 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -189,7 +189,10 @@ export function createRouteService({ } catch { /* the status below is authoritative */ } - if (response.status === 429) throw httpFailure(response, 'Routing'); + // A rate limit or an outage (5xx) is the routing service's failure, not + // a missing route, whatever the body says. + if (response.status === 429 || response.status >= 500) + throw httpFailure(response, 'Routing'); if (payload?.ok === false || response.status === 400) return { ok: false, error: payload?.error || 'no route found' }; if (!response.ok) throw httpFailure(response, 'Routing'); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 89cad9ba8..7da838b45 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { createMcpServer } from '../mcp/index.js'; import { createPlaceSearchService, createRouteService } from '../places.js'; const place = (id, latitude, longitude, extra = {}) => ({ @@ -249,6 +250,41 @@ test('the place and route services read the app routes and translate failures', ); }); +test('a routing outage is reported as unavailable, not as invalid arguments', async () => { + for (const fetchImpl of [ + async () => + Response.json( + { ok: false, error: 'routing service unavailable' }, + { status: 502 }, + ), + async () => { + throw new TypeError('fetch failed'); + }, + ]) { + const catalog = composeCatalog({ + tools: coreTools, + services: { routing: createRouteService({ fetchImpl }), places }, + }); + const server = createMcpServer({ + catalog, + name: 'test', + version: '1.0.0', + }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'plan_route', + arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unavailable'); + assert.doesNotMatch(result.content[0].text, /No walk route/); + } +}); + test('place answers say when the search limits may have left places out', async () => { const five = Array.from({ length: 5 }, (_, index) => place(`p${index}`, 30.27, -97.74), From 536f371ddee7520b07dd6a4043dfcce0c9fdf240 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:52:35 -0800 Subject: [PATCH 2/7] docs(changelog): credit #1509 in the fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1509-route-outage-unavailable.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1509-route-outage-unavailable.md diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md new file mode 100644 index 000000000..3f78f9d7c --- /dev/null +++ b/changelog.d/1509-route-outage-unavailable.md @@ -0,0 +1,4 @@ +- A routing service outage (an error status, no answer or a non-JSON body) + is reported as "routing service unavailable" instead of "no route found", + so voice route planning and the Directions layer no longer blame the + request (quanticsoul4772, #1509). From 1bff1961b4afc773b75908076aeedc7c77fa90ea Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:21:08 -0800 Subject: [PATCH 3/7] fix(routes): keep the 12 s budget from arrival and name the outage to voice Starting the upstream timeout after the outbound queue let a request run up to about 19 s, past the Directions layer's 15 s; the budget again runs from arrival, queue wait included, and a budget spent before the request leaves the queue answers 429 "routing busy" rather than an outage. plan_route threw "Routing HTTP 502" for an outage, and voice passes only the message on; it now throws "Routing service unavailable (HTTP 502)". Co-Authored-By: Claude Opus 5.5 --- changelog.d/1509-route-outage-unavailable.md | 8 ++++---- docs/CURRENT-STATE.md | 16 +++++++++------- server/providers/places/routes.js | 12 +++++++----- src/tooling/placeProviders.test.mjs | 14 ++++++++------ src/tools/places.js | 10 +++++++--- src/tools/queries/places.test.mjs | 18 ++++++++++++++++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md index 3f78f9d7c..89b573c4d 100644 --- a/changelog.d/1509-route-outage-unavailable.md +++ b/changelog.d/1509-route-outage-unavailable.md @@ -1,4 +1,4 @@ -- A routing service outage (an error status, no answer or a non-JSON body) - is reported as "routing service unavailable" instead of "no route found", - so voice route planning and the Directions layer no longer blame the - request (quanticsoul4772, #1509). +- When the routing service is down (an error status, no answer or a non-JSON + body), voice route planning hears "Routing service unavailable" and the + Directions layer shows "Routing unavailable" instead of "no route found", + so neither blames the request (quanticsoul4772, #1509). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 178369f66..1b00542fb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4376,13 +4376,15 @@ service's usage policy states; past a bounded queue the answer is a 429 with a upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a -404), no answer (a 12 s timeout counted from when the request leaves, not -while it waits for an outbound slot; a network error) or a body that is not -JSON answers -`502 routing service unavailable`, which `plan_route` -reports as `unavailable` and the layer as `Routing unavailable (HTTP 502)`; -only OSRM's own refusal (a 400, or a 200 without a route) reads "no route -found". +404), no answer (timeout, network error) or a body that is not JSON answers +`502 routing service unavailable`. `plan_route` throws `Routing service +unavailable (HTTP 502)`, which is what voice hears, and the layer shows +`Routing unavailable (HTTP 502)`; only OSRM's own refusal (a 400, or a 200 +without a route) reads "no route found". The 12 s timeout runs from the +request's arrival, queue wait included; a budget spent before the request +leaves the queue answers 429 "routing busy". A request that waited in the +queue gives OSRM the rest of the 12 s, so a slow answer then still reads as +unavailable. `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 34e64d20d..5f4c41e3a 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -178,14 +178,16 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); - let timer; + // One 12 s budget from arrival, queue wait included, so callers sized to it + // (the Directions layer gives up at 15 s) hear back in time. + const timer = setTimeout(() => controller.abort(), 12000); let osrm; try { 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); + // A budget spent waiting in our own queue is our backlog, not the + // routing service failing. + if (controller.signal.aborted) + throw new RouteBusyError(_upstreamIntervalMs); try { return await fetchImpl(upstream, { signal: controller.signal, diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 1540d455b..7dd45fae3 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -311,15 +311,16 @@ test('callers sharing one failed upstream call all hear the outage, and it is no assert.equal(calls, 2, 'the next request asks the routing service again'); }); -test('a wait for an outbound slot does not count toward the upstream timeout', async (t) => { +test('a budget spent in the outbound queue is reported as busy, not as an outage', async (t) => { _resetRouteUpstreamForTest(); - // The second request waits 13 s for its slot, longer than the 12 s budget. + // The second request waits 13 s for its slot, past the 12 s budget. _setRouteUpstreamIntervalForTest(13_000); t.after(() => _resetRouteUpstreamForTest()); t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() }); const request = install(installRouteMiddleware); - t.mock.method(globalThis, 'fetch', async (_url, { signal }) => { - signal.throwIfAborted(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; return Response.json(osrmRouteWithSteps(2)); }); const first = request( @@ -333,8 +334,9 @@ test('a wait for an outbound slot does not count toward the upstream timeout', a assert.equal((await first).body.ok, true); t.mock.timers.tick(13_000); const result = await second; - assert.equal(result.statusCode, 200); - assert.equal(result.body.ok, true); + assert.equal(result.statusCode, 429); + assert.match(result.body.error, /routing busy/); + assert.equal(calls, 1, 'the spent request never reaches the routing service'); }); /** One OSRM route with `count` maneuvers, for the step tests. */ diff --git a/src/tools/places.js b/src/tools/places.js index 8d67ec5d1..3ecef4855 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -190,9 +190,13 @@ export function createRouteService({ /* the status below is authoritative */ } // A rate limit or an outage (5xx) is the routing service's failure, not - // a missing route, whatever the body says. - if (response.status === 429 || response.status >= 500) - throw httpFailure(response, 'Routing'); + // a missing route, whatever the body says. Voice hears this message. + if (response.status === 429) throw httpFailure(response, 'Routing'); + if (response.status >= 500) + throw new ToolError( + 'unavailable', + `Routing service unavailable (HTTP ${response.status})`, + ); if (payload?.ok === false || response.status === 400) return { ok: false, error: payload?.error || 'no route found' }; if (!response.ok) throw httpFailure(response, 'Routing'); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 7da838b45..4e795e3f1 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -283,6 +283,24 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as assert.equal(result.structuredContent.error, 'unavailable'); assert.doesNotMatch(result.content[0].text, /No walk route/); } + // Voice passes only the message on, so it names the outage itself. + const outage = createRouteService({ + fetchImpl: async () => + Response.json( + { ok: false, error: 'routing service unavailable' }, + { status: 502 }, + ), + }); + await assert.rejects( + outage.route( + [ + { lat: 30, lon: -97 }, + { lat: 30.01, lon: -97.01 }, + ], + 'foot', + ), + { message: 'Routing service unavailable (HTTP 502)' }, + ); }); test('place answers say when the search limits may have left places out', async () => { From fdd153b73dc4a8c419a64c1046ff0e65812eb57c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:38:03 -0800 Subject: [PATCH 4/7] ci: re-run checks after a failed Chrome download on the runner Co-Authored-By: Claude Opus 5.5 From 82e2b716bc31f41cb9f16bd00cbf2e214c4efa13 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 21:21:07 +0400 Subject: [PATCH 5/7] docs(routes): tighten the outage comments and notes Shorter comments, changelog and CURRENT-STATE text for the 502 outage path; the CURRENT-STATE paragraph now also names the 429 exception and which budget expiry answers busy and which answers 502. Text only. --- changelog.d/1509-route-outage-unavailable.md | 7 +++---- docs/CURRENT-STATE.md | 19 ++++++++----------- server/providers/places/routes.js | 19 +++++-------------- src/layers/directions/source.js | 5 ++--- src/tooling/placeProviders.test.mjs | 1 - src/tools/places.js | 3 +-- src/tools/queries/places.test.mjs | 2 +- 7 files changed, 20 insertions(+), 36 deletions(-) diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md index 89b573c4d..653463c51 100644 --- a/changelog.d/1509-route-outage-unavailable.md +++ b/changelog.d/1509-route-outage-unavailable.md @@ -1,4 +1,3 @@ -- When the routing service is down (an error status, no answer or a non-JSON - body), voice route planning hears "Routing service unavailable" and the - Directions layer shows "Routing unavailable" instead of "no route found", - so neither blames the request (quanticsoul4772, #1509). +- Routing outages report "Routing service unavailable" in voice route planning + and "Routing unavailable" in Directions instead of "no route found" + (quanticsoul4772, #1509). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce4cc52ef..28ea6a217 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5447,16 +5447,13 @@ service's usage policy states; past a bounded queue the answer is a 429 with a `Retry-After` rather than a queue that grows until everything times out. The upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route -found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a -404), no answer (timeout, network error) or a body that is not JSON answers -`502 routing service unavailable`. `plan_route` throws `Routing service -unavailable (HTTP 502)`, which is what voice hears, and the layer shows -`Routing unavailable (HTTP 502)`; only OSRM's own refusal (a 400, or a 200 -without a route) reads "no route found". The 12 s timeout runs from the -request's arrival, queue wait included; a budget spent before the request -leaves the queue answers 429 "routing busy". A request that waited in the -queue gives OSRM the rest of the 12 s, so a slow answer then still reads as -unavailable. +found". Upstream failure statuses except 400 and 429, network failures, and +unreadable, oversized or non-JSON bodies return `502 routing service unavailable`. +Voice hears `Routing service unavailable (HTTP 502)`; Directions shows +`Routing unavailable (HTTP 502)`. OSRM 400 responses or successful responses +without a usable route return "no route found". The 12 s upstream budget includes +queue time: expiry before dispatch returns 429 "routing busy" when the queue slot +opens; expiry during the upstream request returns 502. `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at @@ -6867,7 +6864,7 @@ apply to `public/` assets or built-preview serving. - `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. -- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; a routing-service outage answers 502 instead. +- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; routing outages return 502. - `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call. - `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 7d9ca718e..2dbc1aafa 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -72,10 +72,7 @@ class UpstreamRateLimitError extends Error { } } -/** - * Thrown when the routing service failed, did not answer (timeout, network - * error, refused redirect) or did not answer with JSON. - */ +/** Thrown when the routing service failed or sent no JSON; answered as 502. */ class UpstreamUnavailableError extends Error { constructor() { super('routing service unavailable'); @@ -178,14 +175,12 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); - // One 12 s budget from arrival, queue wait included, so callers sized to it - // (the Directions layer gives up at 15 s) hear back in time. + // The 12 s budget includes queue wait, so Directions (15 s) hears back. const timer = setTimeout(() => controller.abort(), 12000); let osrm; try { const upstreamRes = await throughUpstreamGate(async () => { - // A budget spent waiting in our own queue is our backlog, not the - // routing service failing. + // Expiry before dispatch reflects our backlog, not an upstream outage. if (controller.signal.aborted) throw new RouteBusyError(_upstreamIntervalMs); try { @@ -207,8 +202,7 @@ async function fetchRoute({ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5, ); } - // 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. + // OSRM answers 400 for a route it cannot find; other failures are outages. if (upstreamRes.status === 400) { await cancelBody(upstreamRes); return { payload: null, error: 'no route found' }; @@ -222,9 +216,7 @@ async function fetchRoute({ await cancelBody(upstreamRes); 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". + // Read and parse failures are outages; later ones stay "route proxy error". try { osrm = JSON.parse( await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES), @@ -392,7 +384,6 @@ export function installRouteMiddleware( ); } if (upstreamError instanceof UpstreamUnavailableError) { - // Same reason: an outage says nothing about whether a route exists. return unavailable(upstreamError.message); } throw upstreamError; diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index 52ffc0888..e89bd4e83 100644 --- a/src/layers/directions/source.js +++ b/src/layers/directions/source.js @@ -21,7 +21,7 @@ export function createDirectionsSource({ signal, headers: { Accept: 'application/json' }, }); - // A failure page in front of the proxy need not be JSON. + // Proxy error pages may not be JSON. let payload = null; try { payload = await response.json(); @@ -45,8 +45,7 @@ export function createDirectionsSource({ { status: 429, refusal }, ); } - // A 5xx is a routing outage whatever its body says; only the proxy's - // refusals carry an error text worth showing. + // A 5xx body may mislabel an outage as a route refusal. if (response.status >= 500 || (!response.ok && !payload?.error)) throw Object.assign( new Error(`Routing unavailable (HTTP ${response.status})`), diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 96c4ef61f..06553f7bc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -236,7 +236,6 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be assert.equal((await request(route, url)).body.error, error); assert.equal(calls, 1); now += 600001; - // OSRM's own "no route" is a 400 with a JSON code, and stays one. t.mock.method(globalThis, 'fetch', async () => Response.json( { code: 'NoRoute', message: 'Impossible route between points' }, diff --git a/src/tools/places.js b/src/tools/places.js index 3ecef4855..e708a763f 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -189,8 +189,7 @@ export function createRouteService({ } catch { /* the status below is authoritative */ } - // A rate limit or an outage (5xx) is the routing service's failure, not - // a missing route, whatever the body says. Voice hears this message. + // Handle 429 and 5xx before route refusals, regardless of the body. if (response.status === 429) throw httpFailure(response, 'Routing'); if (response.status >= 500) throw new ToolError( diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 4e795e3f1..d818f081b 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -283,7 +283,7 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as assert.equal(result.structuredContent.error, 'unavailable'); assert.doesNotMatch(result.content[0].text, /No walk route/); } - // Voice passes only the message on, so it names the outage itself. + // Voice receives only the message, so it must name the outage. const outage = createRouteService({ fetchImpl: async () => Response.json( From 94ff78be3b7a05ea51d7f74438e0a20b85319a9c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 21:23:35 +0400 Subject: [PATCH 6/7] refactor(routes): inline the single-use 502 responder The outage branch is the only caller, so it writes its 502 directly. Same status, headers and body. --- server/providers/places/routes.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 2dbc1aafa..4c38a21f9 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -267,10 +267,6 @@ 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); @@ -384,7 +380,9 @@ export function installRouteMiddleware( ); } if (upstreamError instanceof UpstreamUnavailableError) { - return unavailable(upstreamError.message); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: upstreamError.message })); + return; } throw upstreamError; } From ca58e113c08aed469df373b6625c17fe01e753cc Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:02:34 -0600 Subject: [PATCH 7/7] test(routes): cover HTML 502 outage in MCP plan_route (#1509) Preserve quanticsoul4772's routing-outage fix and add a focused voice-facing regression for a non-JSON gateway response. --- src/tools/queries/places.test.mjs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index d818f081b..1cc6e6c8a 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -303,6 +303,38 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as ); }); +test('an HTML 502 gateway response tells voice the route service is unavailable', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + routing: createRouteService({ + fetchImpl: async () => + new Response('Bad Gateway', { + status: 502, + headers: { 'content-type': 'text/html' }, + }), + }), + places, + }, + }); + const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'plan_route', + arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unavailable'); + assert.equal( + result.content[0].text, + 'Routing service unavailable (HTTP 502)', + ); +}); + test('place answers say when the search limits may have left places out', async () => { const five = Array.from({ length: 5 }, (_, index) => place(`p${index}`, 30.27, -97.74),