From c24399e99d03d3d472490ac29f6b11db59e8d1f7 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 19 Sep 2026 12:11:55 +0700 Subject: [PATCH 1/2] fix(traffic): say which upstream declined a road load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Street Traffic draws on OpenStreetMap for geometry and TomTom for flow, and the row said "Road data temporarily unavailable" whichever one failed. The reporter of #661 went and verified a TomTom key while every public Overpass mirror was refusing their network with HTTP 406 — a status fetchRoads already had and dropped on the floor. Classify the refusal where the layer talks to Overpass: 429 is "Overpass rate-limited", 504 is "Overpass timed out", anything else is "Overpass refused the road query (HTTP )". The first two are the words alpr's Overpass source already uses; the status is what says which upstream declined, which a single-source layer never has to. The error carries its status as a RoadRequestError, following LiveSourceError in src/sources/live/contract.js, so a caller can branch on the code without parsing English. The catch promotes a classified refusal and keeps the general line for everything else — the branch is on the error type, not on its text, because a dropped socket throws whatever the platform felt like saying and "Failed to fetch" on a panel row helps nobody. Retries, backoff, caching and the mirror rotation are untouched. --- CHANGELOG.md | 7 ++++ docs/CURRENT-STATE.md | 1 + src/layers/traffic/ingestion.js | 15 +++++-- src/layers/traffic/navigation.test.mjs | 36 +++++++++++++++++ src/layers/traffic/source.js | 55 ++++++++++++++++++++++++++ src/layers/traffic/source.test.mjs | 39 +++++++++++++++++- 6 files changed, 149 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4be0a..33174d45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Report which upstream declined a Street Traffic road load. The layer row now + reads `Overpass rate-limited`, `Overpass timed out`, or + `Overpass refused the road query (HTTP 406)` instead of a general + "Road data temporarily unavailable", so a reader is not sent to check a + TomTom key when the public OpenStreetMap mirrors are the side that failed. + Failures the layer cannot classify keep the general line. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c381ac0c..e63817fd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3242,6 +3242,7 @@ silently demoting every later lookup for the session. - HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data. - A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`). - Every mirror is asked with a User-Agent that names the application, its version and the project address (`OVERPASS_USER_AGENT` in `server/providers/overpass/constants.js`), which is what the OSM API usage policy asks for. Mirrors may refuse a client they cannot identify; such a refusal is never treated as data and costs the fan-out that mirror, so the header is what keeps the list at full strength. +- When the fan-out has nothing left to offer, the traffic row says which upstream declined and how: `Overpass rate-limited`, `Overpass timed out`, or `Overpass refused the road query (HTTP 406)`. Street Traffic draws on two upstreams — OpenStreetMap for geometry, TomTom for flow — so a row that only said "road data unavailable" sent readers to check a TomTom key that was never the problem (#661). `roadRequestError` in `src/layers/traffic/source.js` owns the mapping and tags the error with its status. A refusal whose code is unreadable — the layer takes its source by injection, so an adapter may not supply one — reports `Overpass temporarily unavailable` rather than printing `HTTP undefined`, and anything the layer did not classify at all (a dropped connection, a malformed snapshot) still reads `Road data temporarily unavailable`, because a platform exception is not a sentence to put on a panel. ### Share-link v2 layer state (August 2026) diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index de5b56d9f..904ceca04 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -4,6 +4,7 @@ import { TILE_CACHE_MAX_ENTRIES, FAST_FETCH_ALTITUDE, } from './policy.js'; +import { RoadRequestError, roadRequestError } from './source.js'; export function createIngestion({ state: layerState, @@ -67,7 +68,7 @@ export function createIngestion({ ); if (!response.ok) { - throw new Error(`Overpass API returned ${response.status}`); + throw roadRequestError(response.status); } if (!state) { @@ -307,8 +308,16 @@ export function createIngestion({ renderedSomething = true; } catch (e) { if (e?.name === 'AbortError') return; - if (generation === layerState._loadGeneration && !renderedSomething) - layerState._roadError = 'Road data temporarily unavailable'; + if (generation === layerState._loadGeneration && !renderedSomething) { + // A refusal we classified carries its own line; anything else — a + // dropped connection, a malformed snapshot — keeps the general one, + // because `e.message` from the platform is not a sentence anyone + // should have to read off a panel. + layerState._roadError = + e instanceof RoadRequestError + ? e.message + : 'Road data temporarily unavailable'; + } console.warn('[Data:Traffic] Fetch error:', e); } finally { if (generation === layerState._loadGeneration) { diff --git a/src/layers/traffic/navigation.test.mjs b/src/layers/traffic/navigation.test.mjs index e7bd4cc62..c07ae6ad9 100644 --- a/src/layers/traffic/navigation.test.mjs +++ b/src/layers/traffic/navigation.test.mjs @@ -216,3 +216,39 @@ test('parked failures back off and disabling cancels the scheduled retry', async assert.equal(calls, 2); assert.equal(layer.getStats().error, null); }); + +test('a declined road request reaches the row as the reason, not as a shrug', async (t) => { + // Issue #661: with every public Overpass mirror refusing a network, the row + // read "Road data temporarily unavailable" while the console already held + // "Overpass API returned 406". The reporter went looking for a bad TomTom + // key — the other half of this layer, and never the problem. The status the + // code already has must survive the trip to the panel. + let status = 406; + const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status })); + layer.enable(viewer); + await tick(400); + assert.equal( + layer.getStats().error, + 'Overpass refused the road query (HTTP 406)', + ); + + status = 429; + for (let i = 0; i < 20; i++) await tick(1500); + assert.equal( + layer.getStats().error, + 'Overpass rate-limited', + 'a rate limit is a different instruction to the reader than a refusal', + ); +}); + +test('an unclassified road failure keeps the general line', async (t) => { + // The complement of the test above, and the reason this is not just + // `e.message`: a dropped socket throws whatever the platform felt like + // saying, and "Failed to fetch" on a panel row helps nobody. + const { layer, viewer, tick } = setup(t, async () => { + throw new Error('socket hang up'); + }); + layer.enable(viewer); + await tick(400); + assert.equal(layer.getStats().error, 'Road data temporarily unavailable'); +}); diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index d499a4b7b..dbcbb67c1 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -15,6 +15,61 @@ function buildOverpassQuery( return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`; } +/** + * A road request upstream declined, carrying a line fit to show an operator. + * + * The status travels beside the message instead of only inside it, so a caller + * can branch on the code without parsing English — the same shape + * `LiveSourceError` uses in `src/sources/live/contract.js`. + */ +export class RoadRequestError extends Error { + constructor(message, { status = null } = {}) { + super(message); + this.name = 'RoadRequestError'; + this.status = status; + } +} + +/** + * Name an Overpass refusal in the words the traffic row will print. + * + * This layer has two upstreams — OpenStreetMap for road geometry and TomTom + * for flow — and only one of them can be down at a time. A row that says + * nothing more than "road data unavailable" sends the reader to check their + * TomTom key, which is the wrong half of the layer and costs them the + * afternoon. So the status upstream actually returned is reported: 406 from a + * public mirror is not a configuration problem the reader can fix, and saying + * so is the difference between a dead end and a next step. + * + * The first two lines match `alpr`'s Overpass source word for word; this is + * the same vocabulary, not a new one. What the status adds is *which* upstream + * and *how* it declined — the part a single-source layer never needs to say. + * + * Only what the browser can see is claimed. The proxy rotates mirrors behind + * `/api/overpass`, so a refusal arriving here means the proxy had nothing + * better to offer; how many mirrors it tried is not something this side knows, + * and the message does not pretend otherwise. + * @param {number} status - HTTP status the proxy returned; a code that is + * not a finite number is treated as absent rather than printed. + * @returns {RoadRequestError} + */ +export function roadRequestError(status) { + const code = Number.isFinite(status) ? status : null; + const message = + code === 429 + ? 'Overpass rate-limited' + : code === 504 + ? 'Overpass timed out' + : // A source is injected, so a caller's adapter may hand back a + // refusal with no status on it. "HTTP undefined" on a panel row is + // worse than not naming a number, so an unreadable code falls back + // to what `alpr` says when it cannot be more specific either. + code === null + ? 'Overpass temporarily unavailable' + : `Overpass refused the road query (HTTP ${code})`; + return new RoadRequestError(message, { status: code }); +} + /** Supply road responses, flow availability and one decoded flow cache. */ export function createTrafficSource({ fetchImpl = (...args) => globalThis.fetch(...args), diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 8047ddbbd..f7024d81c 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; -import { createTrafficSource } from './source.js'; +import { + RoadRequestError, + createTrafficSource, + roadRequestError, +} from './source.js'; import { clampBoundsAroundCenter } from '../../data/trafficBounds.js'; const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 }; const fixture = readFileSync( @@ -188,3 +192,36 @@ test('road sources decode direction and coordinates before scene construction', ], }); }); + +test('an Overpass refusal is named by status, and keeps the status beside the words', () => { + // The panel prints this string verbatim, so the mapping is part of the + // layer's contract with the reader, not an implementation detail. + assert.equal(roadRequestError(429).message, 'Overpass rate-limited'); + assert.equal(roadRequestError(504).message, 'Overpass timed out'); + assert.equal( + roadRequestError(406).message, + 'Overpass refused the road query (HTTP 406)', + ); + assert.equal( + roadRequestError(500).message, + 'Overpass refused the road query (HTTP 500)', + ); + + // An injected source may refuse without a readable code; a row must never + // print "HTTP undefined" at a reader. + for (const missing of [undefined, null, NaN, 'four-oh-six']) { + const vague = roadRequestError(missing); + assert.equal(vague.message, 'Overpass temporarily unavailable'); + assert.equal(vague.status, null, 'an unreadable code is absent, not zero'); + } + + const refused = roadRequestError(406); + assert.ok(refused instanceof RoadRequestError); + assert.ok(refused instanceof Error); + assert.equal(refused.name, 'RoadRequestError'); + assert.equal( + refused.status, + 406, + 'a caller must be able to branch on the code without parsing English', + ); +}); From 31224d8bd255222612792e713e3bcf141ea1d70e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 24 Sep 2026 22:36:53 -0700 Subject: [PATCH 2/2] fix(traffic): name the proxy's own 502/503 accurately (#665) 502 (every mirror unreachable) and 503 (the local limiter, before any mirror is asked) come from our proxy, so they no longer read as a mirror refusing the query. Credit the fix in CHANGELOG and note the mapping in CURRENT-STATE. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++- docs/CURRENT-STATE.md | 2 +- src/layers/traffic/source.js | 21 ++++++++++++++------- src/layers/traffic/source.test.mjs | 9 +++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f9936a7..527e1a29a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ `Overpass refused the road query (HTTP 406)` instead of a general "Road data temporarily unavailable", so a reader is not sent to check a TomTom key when the public OpenStreetMap mirrors are the side that failed. - Failures the layer cannot classify keep the general line. + The proxy's own 502 (every mirror unreachable) and 503 (local limiter busy) + read `Overpass mirrors unreachable` and `Overpass temporarily unavailable`. + Failures the layer cannot classify keep the general line (daikaginza, #665). - Bound the client terrain-height cache at 20 000 entries with least-recently-used eviction, so a long session no longer retains every coordinate it ever resolved. Consumer reads promote their entry and a batch still reports every point it resolved (Pedro Lobato, #594). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 237e0dc1a..59f021aba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3483,7 +3483,7 @@ silently demoting every later lookup for the session. - HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data. - A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`). - Every mirror is asked with a User-Agent that names the application, its version and the project address (`OVERPASS_USER_AGENT` in `server/providers/overpass/constants.js`), which is what the OSM API usage policy asks for. Mirrors may refuse a client they cannot identify; such a refusal is never treated as data and costs the fan-out that mirror, so the header is what keeps the list at full strength. -- When the fan-out has nothing left to offer, the traffic row says which upstream declined and how: `Overpass rate-limited`, `Overpass timed out`, or `Overpass refused the road query (HTTP 406)`. Street Traffic draws on two upstreams — OpenStreetMap for geometry, TomTom for flow — so a row that only said "road data unavailable" sent readers to check a TomTom key that was never the problem (#661). `roadRequestError` in `src/layers/traffic/source.js` owns the mapping and tags the error with its status. A refusal whose code is unreadable — the layer takes its source by injection, so an adapter may not supply one — reports `Overpass temporarily unavailable` rather than printing `HTTP undefined`, and anything the layer did not classify at all (a dropped connection, a malformed snapshot) still reads `Road data temporarily unavailable`, because a platform exception is not a sentence to put on a panel. +- When the fan-out has nothing left to offer, the traffic row says which upstream declined and how: `Overpass rate-limited`, `Overpass timed out`, or `Overpass refused the road query (HTTP 406)`; the proxy's own 502 (every mirror unreachable) and 503 (local limiter busy) read `Overpass mirrors unreachable` and `Overpass temporarily unavailable`. Street Traffic draws on two upstreams — OpenStreetMap for geometry, TomTom for flow — so a row that only said "road data unavailable" sent readers to check a TomTom key that was never the problem (#661). `roadRequestError` in `src/layers/traffic/source.js` owns the mapping and tags the error with its status. A refusal whose code is unreadable — the layer takes its source by injection, so an adapter may not supply one — reports `Overpass temporarily unavailable` rather than printing `HTTP undefined`, and anything the layer did not classify at all (a dropped connection, a malformed snapshot) still reads `Road data temporarily unavailable`, because a platform exception is not a sentence to put on a panel. ### Share-link v2 layer state (August 2026) diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index dbcbb67c1..b2c992b12 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -60,13 +60,20 @@ export function roadRequestError(status) { ? 'Overpass rate-limited' : code === 504 ? 'Overpass timed out' - : // A source is injected, so a caller's adapter may hand back a - // refusal with no status on it. "HTTP undefined" on a panel row is - // worse than not naming a number, so an unreadable code falls back - // to what `alpr` says when it cannot be more specific either. - code === null - ? 'Overpass temporarily unavailable' - : `Overpass refused the road query (HTTP ${code})`; + : // Our own proxy answers these, not a mirror: 502 when every mirror + // failed at the network level, 503 from its local concurrency + // limiter before any mirror was asked. Neither is a refusal. + code === 502 + ? 'Overpass mirrors unreachable' + : code === 503 + ? 'Overpass temporarily unavailable' + : // A source is injected, so a caller's adapter may hand back a + // refusal with no status on it. "HTTP undefined" on a panel row is + // worse than not naming a number, so an unreadable code falls back + // to what `alpr` says when it cannot be more specific either. + code === null + ? 'Overpass temporarily unavailable' + : `Overpass refused the road query (HTTP ${code})`; return new RoadRequestError(message, { status: code }); } diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index f7024d81c..b492223eb 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -207,6 +207,15 @@ test('an Overpass refusal is named by status, and keeps the status beside the wo 'Overpass refused the road query (HTTP 500)', ); + // 502 and 503 come from the local proxy (mirrors unreachable, local + // limiter busy), so they must not read as a mirror refusing the query. + assert.equal(roadRequestError(502).message, 'Overpass mirrors unreachable'); + assert.equal( + roadRequestError(503).message, + 'Overpass temporarily unavailable', + ); + assert.equal(roadRequestError(503).status, 503); + // An injected source may refuse without a readable code; a row must never // print "HTTP undefined" at a reader. for (const missing of [undefined, null, NaN, 'four-oh-six']) {