diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md new file mode 100644 index 000000000..653463c51 --- /dev/null +++ b/changelog.d/1509-route-outage-unavailable.md @@ -0,0 +1,3 @@ +- 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 cbb939dd8..f0a31edc3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5447,7 +5447,14 @@ 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". 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 200 maneuvers — a route with more is reported as cut off, in the row summary @@ -6691,7 +6698,7 @@ request viewbox and continues searching without that geographic preference. ### News (November 2026) -- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article. +- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article. - **Time.** The layer reads its time from the world clock (`worldClock.timeFor('news')`, passed as `now` in `src/app/constructCatalog.js`): report ages, the 24-hour window, "today", fading and the pressing and card rules all count from it. Core does not follow the clock for the layer, so this is the wall clock and nothing changes. A host whose news source can answer a past time may follow it for `news`; the layer then shows that time's news as it was then, with ages counted from the timeline. - **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report. - **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working. @@ -6858,7 +6865,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; 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 7b0a81449..4c38a21f9 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -72,6 +72,14 @@ class UpstreamRateLimitError extends Error { } } +/** Thrown when the routing service failed or sent no JSON; answered as 502. */ +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 +175,26 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); + // 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(() => - 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 () => { + // Expiry before dispatch reflects our backlog, not an upstream outage. + if (controller.signal.aborted) + throw new RouteBusyError(_upstreamIntervalMs); + 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 +202,28 @@ async function fetchRoute({ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5, ); } - if (!upstreamRes.ok) { + // 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' }; } + 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(); + } + // Read and parse failures are outages; later ones stay "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); } @@ -355,6 +379,11 @@ export function installRouteMiddleware( upstreamError.retryAfterSec, ); } + if (upstreamError instanceof UpstreamUnavailableError) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: upstreamError.message })); + return; + } throw upstreamError; } if (error || !payload) return fail(error || 'no route found'); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index aecf87b9a..7cf96fe29 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -69,6 +69,7 @@ export function createApplicationCatalog({ signal, metadata = APPLICATION_LAYER_METADATA, vesselOptions, + newsOptions, resolveAsset, nepalBoundaryResolver, streetLevelProviders, @@ -146,8 +147,9 @@ export function createApplicationCatalog({ // Ages, the window and the counts read the world clock: a host that // follows it for the layer shows the news at the time on the timeline. createApplicationNews({ - source: sources.news, now: worldClock.timeFor(NEWS_LAYER_ID), + ...newsOptions, + source: sources.news, }), createApplicationWildlifeSightings({ source: sources['wildlife-sightings'], diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index 2766f83d4..e89bd4e83 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(); + // Proxy error pages may 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,8 @@ export function createDirectionsSource({ { status: 429, refusal }, ); } - if (!response.ok && !payload?.error) + // 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})`), { 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/layers/news/index.js b/src/layers/news/index.js index 6749b3501..bd7024ab9 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -48,6 +48,7 @@ import { countryGroups, countryOnly, disputedLabel, + eventPicture, feedCardView, feedQueryFor, hotspotLabel, @@ -63,6 +64,7 @@ import { normalizeFilters, passesFilters, reportMarkerPosition, + reportSeverity, reportsInView, severityStyle, shortTitle, @@ -167,6 +169,13 @@ const emptyInView = () => ({ * chosen: outlines (disputed areas dotted) and the geocoded reports as * markers, the big events as picture cards. The News panel renders the feed * this layer publishes through getRowControls().feed. + * + * `pictureCards(cards, { selectedId })`, when given, draws the picture cards + * instead of the layer: it is called on every render with one card per + * pictured event on the map, in rank order (`{ id, title, lat, lon, + * publishedAt, severity, color, image, video, credit }`), and the selected + * report's id or null, whose own card the layer still draws at its marker; it + * is called with no cards when the layer is turned off. */ export function createNewsLayer({ source, @@ -178,6 +187,7 @@ export function createNewsLayer({ openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'), getViewCentre = viewCentre, registerCredit = registerDynamicCredit, + pictureCards: drawPictureCards = null, } = {}) { if ( typeof source?.getHotspots !== 'function' || @@ -185,6 +195,8 @@ export function createNewsLayer({ ) throw new TypeError('News requires a news source'); if (!overlayHost) throw new TypeError('News requires an overlay host'); + if (drawPictureCards !== null && typeof drawPictureCards !== 'function') + throw new TypeError('News picture cards must be a function'); let viewer = null, dataSource = null, @@ -679,6 +691,43 @@ export function createNewsLayer({ } } + // With a `pictureCards` function the host draws the picture cards: one per + // event on the map with a picture (its most severe, then newest report), + // most severe then newest first, at its marker. The host chooses which + // stand; a click on one calls setParams({ reportId, focus: true }). + function hostCards(drawn) { + const events = new Set(); + return drawn + .filter((r) => r.pins.length && onMap(r) && eventPicture(r)) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + (b.event?.lastAt || b.publishedAt || '').localeCompare( + a.event?.lastAt || a.publishedAt || '', + ), + ) + .filter((report) => { + const event = report.event?.id || report.id; + if (events.has(event)) return false; + events.add(event); + return true; + }) + .map((report) => { + const picture = eventPicture(report); + return { + id: report.id, + title: report.titleEn || report.title, + ...reportMarkerPosition(report, 0), + publishedAt: report.event?.lastAt || report.publishedAt || null, + severity: reportSeverity(report), + color: severityStyle(report).color, + image: picture.url, + video: picture.video || null, + credit: picture.credit, + }; + }); + } + // The selected report's card: what, where, how attested, the link to the // original article ("Source: host ↗"), which a click on it opens, and the // service it came through. @@ -775,7 +824,11 @@ export function createNewsLayer({ .filter((target) => target.position); horizon.update(); updatePulse(); - pictureCards(drawn, current, labels); + if (drawPictureCards) + drawPictureCards(enabled ? hostCards(drawn) : [], { + selectedId: enabled ? selectedId : null, + }); + else pictureCards(drawn, current, labels); selectedCard(current, labels); if (enabled) overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false }); @@ -1442,6 +1495,7 @@ export function createNewsLayer({ } overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID); overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + drawPictureCards?.([], { selectedId: null }); requestRender(); notify(); }, diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 3a677d4db..4b260790d 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -46,6 +46,7 @@ function fixture( centre = () => ({ lat: 49, lon: 36 }), credits = [], now = () => NOW, + pictureCards, } = {}, ) { const calls = { feed: [], hotspots: 0, outline: 0 }; @@ -118,6 +119,7 @@ function fixture( credits.push(credit.key); return true; }, + ...(pictureCards ? { pictureCards } : {}), }); // The context store announces selections on `window`, which Node lacks. const originalWindow = globalThis.window; @@ -227,6 +229,85 @@ test('the severity floor changes what is shown, not what is read', async (t) => ); }); +test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => { + const picture = (url, extra = {}) => ({ + size: 1, + lastAt: '2026-10-28T09:00:00.000Z', + sides: [], + image: { url, publisher: 'Example', ...extra }, + }); + const reports = [ + report('single', { + severity: 1, + event: picture('https://example.com/single.jpg', { + video: 'https://example.com/single.mp4', + }), + }), + report('major', { + severity: 3, + event: { + ...picture('https://example.com/major.jpg'), + id: 'event-major', + size: 3, + }, + }), + // Another report of the same event: its picture stands once. + report('repeat', { + severity: 2, + event: { + ...picture('https://example.com/major.jpg'), + id: 'event-major', + size: 3, + }, + }), + report('bare', { severity: 2 }), + ]; + const handed = []; + const { layer, entries } = fixture(t, { + height: 500_000, + source: { getFeed: async () => ({ reports, nextBefore: null }) }, + pictureCards: (cards, state) => handed.push({ cards, ...state }), + }); + layer.enable(); + await layer.update(); + layer.setParams({ filters: { minSeverity: 0 } }); + const { cards, selectedId } = handed.at(-1); + assert.equal(selectedId, null); + // A single source is enough; a report without a picture has none. + assert.deepEqual( + cards.map((card) => card.id), + ['major', 'single'], + ); + assert.equal(cards[1].image, 'https://example.com/single.jpg'); + assert.equal(cards[1].video, 'https://example.com/single.mp4'); + assert.equal(cards[0].video, null); + assert.equal(cards[0].credit, 'Example'); + assert.ok( + Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5, + ); + // The layer paints none of its own. + assert.ok( + entries.flat().every((entry) => !entry.id.startsWith('event-card:')), + ); + // The selected report is named, pictured or not. + layer.setParams({ reportId: 'bare' }); + assert.equal(handed.at(-1).selectedId, 'bare'); + layer.disable(); + assert.deepEqual(handed.at(-1), { cards: [], selectedId: null }); +}); + +test('picture cards must be drawn by a function', () => { + assert.throws( + () => + createNewsLayer({ + source: { getHotspots() {}, getFeed() {} }, + overlayHost: {}, + pictureCards: 'yes', + }), + TypeError, + ); +}); + test('Local reads every report once, lists those near the view and takes the map over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let centre = { lat: 49, lon: 36 }; diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 4ae9558ff..06553f7bc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -236,23 +236,108 @@ 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 }), + 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 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, 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); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + 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, 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. */ function osrmRouteWithSteps(count) { return { diff --git a/src/tools/places.js b/src/tools/places.js index 4a715f389..e708a763f 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -189,7 +189,13 @@ export function createRouteService({ } catch { /* the status below is authoritative */ } + // 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( + '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 89cad9ba8..1cc6e6c8a 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,91 @@ 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/); + } + // Voice receives only the message, so it must name the outage. + 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('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),