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 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-11-02 09:21:08 -08:00
1 parent 536f371dde
commit 1bff1961b4
6 files changed
+53 -25

No files matched your search

+4 -4
View File
@@ -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).
+9 -7
View File
@@ -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
+7 -5
View File
@@ -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,
+8 -6
View File
@@ -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. */
+7 -3
View File
@@ -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');
+18
View File
@@ -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 () => {