mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #665 from daikaginza/fix/traffic-road-error-diagnosis
fix(traffic): say which upstream declined a road load (refs #661)
This commit is contained in:
6 files changed
+167
-4
No files matched your search
@@ -1,5 +1,14 @@
|
||||
# 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.
|
||||
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).
|
||||
|
||||
- Release CCTV media streams whose upstream falls silent after answering. The
|
||||
|
||||
@@ -3483,6 +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)`; 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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -15,6 +15,68 @@ 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'
|
||||
: // 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 });
|
||||
}
|
||||
|
||||
/** Supply road responses, flow availability and one decoded flow cache. */
|
||||
export function createTrafficSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
|
||||
@@ -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,45 @@ 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)',
|
||||
);
|
||||
|
||||
// 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']) {
|
||||
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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user