fix(overpass): rotate mirrors on any refusal, and never cache one as data
The proxy lists four mirrors and rotated on 5xx alone, so a 4xx ended the fan-out. Measured against the live services: overpass-api.de and its lz4 alias answer 406 to this proxy's User-Agent, while overpass.kumi.systems and overpass.private.coffee answer 200 to the byte-identical request. Every Overpass-backed feature — road geometry, annotation outlines, place lookup — was failing on an Apache error page with two healthy mirrors untried. The refusal was then cached. The cache guard read `status < 500`, so the error page was written to memory and to disk, and the serve-stale guard used the same threshold and declined to replace it. Boundary-class queries hold a month-long TTL: four of twenty-three cached entries on this machine held that 406, dated days after the mirror had stopped refusing. Both decisions now go through one predicate. A payload is data only when it is a 2xx that is neither rate-limited nor a body-level runtime error, so what may be cached and what may be replaced by a stale entry cannot drift apart again. A refusal every mirror agrees on is still reported with the first mirror's status and body, so a malformed query says what upstream said — after every mirror has had its chance, not instead of it. fetchOverpassPayload takes injectable endpoints and fetch so the rotation is covered without a live mirror; restoring either half of the old behaviour fails the new tests.
This commit is contained in:
parent
9dfb5369e8
commit
973b4e147f
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Overpass proxy now rotates to the next mirror on any non-2xx upstream
|
||||
response, not only on 5xx. `overpass-api.de` and its `lz4` alias answer 406 to
|
||||
the proxy's User-Agent while two of the configured mirrors answer 200 to the
|
||||
identical request, so the fan-out stopped at the first refusal with healthy
|
||||
mirrors untried. The refusal was also cached to memory and disk and served as
|
||||
data — boundary-class queries hold a month-long TTL — which affected every
|
||||
Overpass-backed feature: road geometry, annotation outlines and place lookup.
|
||||
|
||||
## [0.1.1] — 2026-09-01 — Installation and live-data fixes
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -2006,6 +2006,12 @@ silently demoting every later lookup for the session.
|
|||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
### Overpass proxy mirror rotation (September 2026)
|
||||
|
||||
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` is the single predicate deciding what counts as an answer, and it governs BOTH what enters the cache and when a stale entry may stand in, so the two cannot drift apart. Only a 2xx that is neither rate-limited nor a body-level runtime error is data.
|
||||
- The rotation used to trigger on 5xx alone, so a 4xx ended the fan-out and was returned, cached to memory AND disk, and served to the client as data. Measured against the live mirrors: `overpass-api.de` and `lz4.overpass-api.de` answer **406** to the proxy's `User-Agent` while `overpass.kumi.systems` and `overpass.private.coffee` answer **200** to the byte-identical request — so every Overpass-backed feature failed on an Apache error page with two healthy mirrors untried, and four of twenty-three cached entries on the machine this was found on held that error page. Boundary-class queries carry a month-long TTL, so one refusal outlived its outage by weeks.
|
||||
- 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`).
|
||||
|
||||
### Share-link v2 layer state (August 2026)
|
||||
|
||||
- Generated share links use a deterministic v2 hash. Existing camera, visual,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
// OVERPASS PROXY — which upstream answers count as an answer.
|
||||
//
|
||||
// The proxy fans out across four public mirrors. What decides whether a mirror
|
||||
// has answered is one predicate, and it is used TWICE: once to decide what
|
||||
// enters the cache, and once to decide when a stale entry may stand in. When
|
||||
// those two drifted, a refusal was written to memory and disk AND served as
|
||||
// data — and boundary-class queries hold a month-long TTL, so a single refusal
|
||||
// outlived the outage that caused it by weeks.
|
||||
//
|
||||
// The refusal was real and measured: overpass-api.de and its lz4 alias answer
|
||||
// 406 to this proxy's User-Agent, while kumi.systems and private.coffee answer
|
||||
// 200 to the identical request. Only 5xx rotated mirrors, so the fan-out
|
||||
// stopped at the first refusal with two healthy mirrors untried, and four of
|
||||
// twenty-three cached entries on this machine held an Apache error page.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchOverpassPayload, overpassPayloadIsData } from '../vite.config.js';
|
||||
|
||||
const ENDPOINTS = ['https://a.example/api', 'https://b.example/api', 'https://c.example/api'];
|
||||
|
||||
/** Answer each endpoint from a map of url → {status, body}; record the order. */
|
||||
function mirrors(byUrl) {
|
||||
const tried = [];
|
||||
const fetchImpl = async (url) => {
|
||||
tried.push(url);
|
||||
const answer = byUrl[url];
|
||||
if (answer instanceof Error) throw answer;
|
||||
return { status: answer.status, headers: { get: () => answer.contentType || 'application/json' } };
|
||||
};
|
||||
return { fetchImpl, tried, readBody: async (_, __) => byUrl[tried[tried.length - 1]]?.body ?? '' };
|
||||
}
|
||||
|
||||
const run = (byUrl) => {
|
||||
const m = mirrors(byUrl);
|
||||
return fetchOverpassPayload('data=x', 1e6, {
|
||||
endpoints: ENDPOINTS,
|
||||
fetchImpl: m.fetchImpl,
|
||||
readBody: m.readBody,
|
||||
simplify: (body) => body,
|
||||
}).then((payload) => ({ payload, tried: m.tried }), (error) => ({ error, tried: m.tried }));
|
||||
};
|
||||
|
||||
const DATA = { status: 200, body: '{"elements":[]}' };
|
||||
|
||||
// ── The predicate ────────────────────────────────────────────────────────────
|
||||
|
||||
test('only a 2xx that is neither rate-limited nor a runtime error is data', () => {
|
||||
assert.equal(overpassPayloadIsData({ status: 200 }), true);
|
||||
assert.equal(overpassPayloadIsData({ status: 204 }), true);
|
||||
|
||||
// The measured refusal, and its neighbours. `< 500` admitted every one.
|
||||
for (const status of [400, 403, 406, 410, 429]) {
|
||||
assert.equal(overpassPayloadIsData({ status }), false, `${status} is not data`);
|
||||
}
|
||||
assert.equal(overpassPayloadIsData({ status: 502 }), false);
|
||||
// A 200 can still not be data: Overpass reports runtime failures in the body.
|
||||
assert.equal(overpassPayloadIsData({ status: 200, runtimeError: true }), false);
|
||||
assert.equal(overpassPayloadIsData({ status: 200, rateLimited: true }), false);
|
||||
assert.equal(overpassPayloadIsData({}), false);
|
||||
assert.equal(overpassPayloadIsData(null), false);
|
||||
});
|
||||
|
||||
// ── The fan-out ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a refusal moves to the next mirror instead of ending the fan-out', async () => {
|
||||
// The exact shape measured against the live mirrors.
|
||||
const { payload, tried } = await run({
|
||||
[ENDPOINTS[0]]: { status: 406, contentType: 'text/html', body: '<!DOCTYPE HTML><title>406</title>' },
|
||||
[ENDPOINTS[1]]: DATA,
|
||||
[ENDPOINTS[2]]: DATA,
|
||||
});
|
||||
|
||||
assert.equal(payload.status, 200);
|
||||
assert.equal(payload.endpoint, ENDPOINTS[1]);
|
||||
assert.deepEqual(tried, ENDPOINTS.slice(0, 2), 'the healthy mirror must be reached, and no further');
|
||||
});
|
||||
|
||||
test('the first mirror to answer wins, and the rest are left alone', async () => {
|
||||
const { payload, tried } = await run({
|
||||
[ENDPOINTS[0]]: DATA, [ENDPOINTS[1]]: DATA, [ENDPOINTS[2]]: DATA,
|
||||
});
|
||||
|
||||
assert.equal(payload.endpoint, ENDPOINTS[0]);
|
||||
assert.deepEqual(tried, [ENDPOINTS[0]]);
|
||||
});
|
||||
|
||||
test('a refusal every mirror agrees on is reported, not swallowed', async () => {
|
||||
// A genuinely bad query must still say what upstream said — but only after
|
||||
// every mirror has had its chance to answer it.
|
||||
const refusal = { status: 400, body: 'line 1: parse error' };
|
||||
const { payload, tried } = await run({
|
||||
[ENDPOINTS[0]]: refusal, [ENDPOINTS[1]]: refusal, [ENDPOINTS[2]]: refusal,
|
||||
});
|
||||
|
||||
assert.equal(payload.status, 400);
|
||||
assert.equal(payload.endpoint, ENDPOINTS[0], 'the FIRST refusal is the one reported');
|
||||
assert.deepEqual(tried, ENDPOINTS);
|
||||
assert.equal(overpassPayloadIsData(payload), false, 'so it is neither cached nor served as data');
|
||||
});
|
||||
|
||||
test('a mirror that throws is no different from one that refuses', async () => {
|
||||
const { payload, tried } = await run({
|
||||
[ENDPOINTS[0]]: new Error('ECONNRESET'),
|
||||
[ENDPOINTS[1]]: { status: 503, body: 'busy' },
|
||||
[ENDPOINTS[2]]: DATA,
|
||||
});
|
||||
|
||||
assert.equal(payload.endpoint, ENDPOINTS[2]);
|
||||
assert.deepEqual(tried, ENDPOINTS);
|
||||
});
|
||||
|
||||
test('when every mirror is unreachable the caller gets a throw, not a payload', async () => {
|
||||
const { error, payload } = await run({
|
||||
[ENDPOINTS[0]]: new Error('ECONNRESET'),
|
||||
[ENDPOINTS[1]]: new Error('ETIMEDOUT'),
|
||||
[ENDPOINTS[2]]: new Error('ENOTFOUND'),
|
||||
});
|
||||
|
||||
assert.equal(payload, undefined);
|
||||
assert.match(error.message, /ENOTFOUND/);
|
||||
});
|
||||
|
|
@ -2558,16 +2558,39 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
|||
* @param {number} [maxResponseBytes] Endpoint-specific response cap.
|
||||
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
|
||||
*/
|
||||
async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES) {
|
||||
/**
|
||||
* True only for an upstream response that is actually Overpass data.
|
||||
*
|
||||
* The proxy caches on this and serves stale on its negation, so the two
|
||||
* decisions cannot drift apart: a payload that is not data must never be
|
||||
* written to the cache and must always be eligible for a stale replacement.
|
||||
* @param {{status: number, rateLimited?: boolean, runtimeError?: boolean}} payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function overpassPayloadIsData(payload) {
|
||||
const status = Number(payload?.status);
|
||||
return Number.isFinite(status)
|
||||
&& status >= 200 && status < 300
|
||||
&& !payload.rateLimited
|
||||
&& !payload.runtimeError;
|
||||
}
|
||||
|
||||
export async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, {
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
} = {}) {
|
||||
let lastError = null;
|
||||
let lastRateLimitPayload = null;
|
||||
let lastRefusalPayload = null;
|
||||
|
||||
for (const endpoint of OVERPASS_UPSTREAMS) {
|
||||
for (const endpoint of endpoints) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const upstream = await fetch(endpoint, {
|
||||
const upstream = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
|
|
@ -2577,7 +2600,7 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
|||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await readResponseTextCapped(upstream, maxResponseBytes);
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
const status = upstream.status;
|
||||
const rateLimited = status === 429 || overpassLooksRateLimited(responseBody);
|
||||
|
|
@ -2601,14 +2624,23 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
|||
lastError = new Error(`Overpass runtime error (${endpoint})`);
|
||||
continue;
|
||||
}
|
||||
if (status >= 500) {
|
||||
// Anything but 2xx is this mirror declining, not an answer. Only 5xx used
|
||||
// to rotate, so a 4xx ended the fan-out and was returned — and cached —
|
||||
// as data: overpass-api.de and its lz4 alias answer 406 to this proxy's
|
||||
// User-Agent while kumi.systems and private.coffee answer 200 to the very
|
||||
// same request, so every Overpass-backed layer failed on an Apache error
|
||||
// page with two healthy mirrors untried. The first refusal is kept so a
|
||||
// genuinely bad query still reports what upstream said, but only after
|
||||
// every mirror has had the chance to answer it.
|
||||
if (status < 200 || status >= 300) {
|
||||
if (!lastRefusalPayload) lastRefusalPayload = payload;
|
||||
lastError = new Error(`Overpass upstream returned ${status} (${endpoint})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success: decimate giant boundary geometry before it reaches the cache,
|
||||
// the disk, or the client (what makes the 32 MB read cap safe to hold).
|
||||
payload.body = simplifyOverpassPayloadBody(payload.body);
|
||||
payload.body = simplify(payload.body);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
|
@ -2618,6 +2650,7 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
|||
}
|
||||
|
||||
if (lastRateLimitPayload) return lastRateLimitPayload;
|
||||
if (lastRefusalPayload) return lastRefusalPayload;
|
||||
throw lastError || new Error('All Overpass upstreams failed');
|
||||
}
|
||||
|
||||
|
|
@ -2710,7 +2743,11 @@ function overpassProxy() {
|
|||
_overpassConcurrent += 1;
|
||||
const requestPromise = fetchOverpassPayload(safeBody)
|
||||
.then((payload) => {
|
||||
if (payload.status < 500 && !payload.rateLimited && !payload.runtimeError) {
|
||||
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
|
||||
// refusal was written to memory AND disk — and boundary-class
|
||||
// queries hold a month-long TTL, so a single 406 outlived the
|
||||
// outage that caused it.
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
|
|
@ -2728,7 +2765,7 @@ function overpassProxy() {
|
|||
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
|
||||
// error): last-good roads beat an empty layer — serve stale from
|
||||
// memory or disk at ANY age before surfacing the failure.
|
||||
if (payload.rateLimited || payload.runtimeError || payload.status >= 500) {
|
||||
if (!overpassPayloadIsData(payload)) {
|
||||
const stale = _overpassCache.get(cacheKey) || await readOverpassDisk(cacheKey, Infinity);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
|
|
|
|||
Loading…
Reference in New Issue