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] 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 () => {