fix(overpass): reject legacy cached refusals and share stale recovery

This commit is contained in:
Bilawal Sidhu 2026-09-03 21:39:33 -05:00
parent 973b4e147f
commit 7ffa421932
5 changed files with 171 additions and 35 deletions

View File

@ -14,6 +14,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
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.
- Existing cached refusals are now ignored immediately, including during
stale-data fallback. Concurrent identical requests share the same last-good
fallback when all mirrors refuse, without duplicating upstream requests.
## [0.1.1] — 2026-09-01 — Installation and live-data fixes

View File

@ -2008,8 +2008,8 @@ silently demoting every later lookup for the session.
### 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.
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache.
- 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`).
### Share-link v2 layer state (August 2026)

View File

@ -16,9 +16,9 @@ import {
test('preflight checks memory, in-flight, then disk before consuming limiter quota', async () => {
const key = 'normalized query';
const fresh = { id: 'memory', cachedAt: 900 };
const joined = { id: 'inflight', cachedAt: 950 };
const disk = { id: 'disk', cachedAt: 975 };
const fresh = { id: 'memory', status: 200, cachedAt: 900 };
const joined = { id: 'inflight', status: 200, cachedAt: 950 };
const disk = { id: 'disk', status: 200, cachedAt: 975 };
let diskReads = 0;
let limiterCalls = 0;
const allowUpstream = () => { limiterCalls += 1; return true; };
@ -39,7 +39,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
const inFlightHit = await resolveOverpassPreflight({
cacheKey: key,
memoryCache: new Map([[key, { id: 'stale', cachedAt: 0 }]]),
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
inFlight: new Map([[key, Promise.resolve(joined)]]),
readDisk: async () => { diskReads += 1; return disk; },
allowUpstream,
@ -84,6 +84,21 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
assert.equal(denied.source, 'RATE_LIMITED');
});
test('preflight treats cached refusals as misses without spending extra quota', async () => {
for (const invalid of [{ status: 406 }, { status: 200, runtimeError: true }]) {
let admissions = 0;
const result = await resolveOverpassPreflight({
cacheKey: 'refused',
memoryCache: new Map([['refused', { ...invalid, cachedAt: Date.now() }]]),
inFlight: new Map(),
readDisk: async () => ({ ...invalid, cachedAt: Date.now() }),
allowUpstream: () => { admissions++; return true; },
});
assert.equal(result.source, 'UPSTREAM');
assert.equal(admissions, 1);
}
});
/** Synthetic dense ring: N points on a circle with sub-tolerance jitter. */
function denseRing(n, { latC = 37.5, lonC = 14.2, radiusDeg = 0.5 } = {}) {
const pts = [];

View File

@ -1,22 +1,17 @@
// 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.
// One predicate governs cache reads, writes, and stale fallback. A mirror's
// refusal must neither end the search for healthy alternatives nor persist as
// data under the week/month-long cache TTLs. These cases use no live providers.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fetchOverpassPayload, overpassPayloadIsData } from '../vite.config.js';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { Readable } from 'node:stream';
import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js';
const ENDPOINTS = ['https://a.example/api', 'https://b.example/api', 'https://c.example/api'];
@ -44,6 +39,31 @@ const run = (byUrl) => {
const DATA = { status: 200, body: '{"elements":[]}' };
test('disk cache rejects old refusals for fresh and stale reads but preserves last-good data', async () => {
const key = `overpass-cache-regression-${randomUUID()}`;
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
const file = path.join(directory, `${createHash('sha1').update(key).digest('hex')}.json`);
await mkdir(directory, { recursive: true });
try {
for (const refusal of [
{ status: 406 }, { status: 429 }, { status: 503 },
{ status: 200, rateLimited: true }, { status: 200, runtimeError: true },
]) {
await writeFile(file, JSON.stringify({ ...DATA, cachedAt: Date.now(), ...refusal }));
assert.equal(await readOverpassDisk(key, 60000), null, `fresh ${JSON.stringify(refusal)}`);
assert.equal(await readOverpassDisk(key, Infinity), null, `stale ${JSON.stringify(refusal)}`);
}
const good = { ...DATA, cachedAt: Date.now() - 120000 };
await writeFile(file, JSON.stringify(good));
assert.equal(await readOverpassDisk(key, 60000), null, 'expired good data misses normal TTL');
assert.deepEqual(await readOverpassDisk(key, Infinity), good, 'last-good data survives an outage');
await writeFile(file, '{invalid');
assert.equal(await readOverpassDisk(key, Infinity), null, 'corrupt cache is ignored');
} finally {
await unlink(file);
}
});
// ── The predicate ────────────────────────────────────────────────────────────
test('only a 2xx that is neither rate-limited nor a runtime error is data', () => {
@ -121,3 +141,85 @@ test('when every mirror is unreachable the caller gets a throw, not a payload',
assert.equal(payload, undefined);
assert.match(error.message, /ENOTFOUND/);
});
test('production reader rotates past oversized, runtime-error and rate-limited bodies', async () => {
for (const [status, body] of [
[200, 'x'.repeat(200)], [200, '{"remark":"runtime error: timed out","elements":[]}'],
[200, 'rate_limited'], [429, 'busy'], [403, 'forbidden'],
]) {
const tried = [];
const payload = await fetchOverpassPayload('data=x', 100, {
endpoints: ENDPOINTS,
fetchImpl: async (url) => {
tried.push(url);
return new Response(tried.length === 1 ? body : DATA.body, {
status: tried.length === 1 ? status : 200,
headers: { 'content-type': 'application/json' },
});
},
});
assert.equal(payload.body, DATA.body);
assert.deepEqual(tried, ENDPOINTS.slice(0, 2));
}
});
function proxyHandler() {
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
const routes = new Map();
plugin.configureServer({ middlewares: { use: (route, handler) => routes.set(route, handler) } });
return routes.get('/api/overpass');
}
function invoke(handler, body) {
const req = Readable.from([Buffer.from(body)]);
Object.assign(req, { method: 'POST', headers: {}, socket: { remoteAddress: '127.0.0.1' } });
return new Promise((resolve, reject) => {
const res = {
writeHead(status, headers) { this.status = status; this.headers = headers; },
end(body) { resolve({ status: this.status, headers: this.headers, body }); },
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('coalesced outage callers both receive last-good data, never a cached refusal', async (t) => {
const handler = proxyHandler();
for (const status of [406, 503, 429]) {
const query = `[out:json][timeout:12];node(around:10,30.27,-97.74)["name"="${randomUUID()}"];out;`;
const body = `data=${encodeURIComponent(query)}`;
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`);
await mkdir(directory, { recursive: true });
const stale = { ...DATA, cachedAt: Date.now() - 40 * 86400000 };
await writeFile(file, JSON.stringify(stale));
const entered = Promise.withResolvers();
const release = Promise.withResolvers();
let fetches = 0;
const mock = t.mock.method(globalThis, 'fetch', async () => {
fetches++;
entered.resolve();
await release.promise;
return new Response('upstream unavailable', { status });
});
try {
const first = invoke(handler, body);
await entered.promise;
const second = invoke(handler, body);
// The second request consumes its in-memory stream and joins the pending
// promise before releasing upstream. No network or elapsed-time sleep.
await new Promise(resolve => setImmediate(resolve));
release.resolve();
for (const response of await Promise.all([first, second])) {
assert.equal(response.status, 200, `${status}: both callers use last-good data`);
assert.equal(response.body, DATA.body);
assert.equal(response.headers['X-Overpass-Cache'], 'STALE');
}
assert.equal(fetches, 4, 'one shared, bounded mirror sequence');
assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), stale);
} finally {
release.resolve();
mock.mock.restore();
await unlink(file);
}
}
});

View File

@ -342,11 +342,14 @@ function overpassDiskPath(cacheKey) {
* serve-stale path when every mirror is down).
* @returns {Promise<?Object>} Payload with cachedAt, or null.
*/
async function readOverpassDisk(cacheKey, maxAgeMs) {
export async function readOverpassDisk(cacheKey, maxAgeMs) {
try {
const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8');
const payload = JSON.parse(raw);
if (!payload || typeof payload.body !== 'string' || !Number.isFinite(payload.cachedAt)) return null;
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
// them on both fresh and stale reads so an upgrade can recover immediately.
if (!overpassPayloadIsData(payload)) return null;
if (Date.now() - payload.cachedAt > maxAgeMs) return null;
return payload;
} catch {
@ -387,18 +390,24 @@ export async function resolveOverpassPreflight({
cacheMs = OVERPASS_CACHE_MS,
}) {
const cached = memoryCache.get(cacheKey);
if (cached && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached };
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached };
const pending = inFlight.get(cacheKey);
if (pending) return { source: 'INFLIGHT', payload: await pending };
const disk = await readDisk();
if (disk) return { source: 'DISK', payload: disk };
if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk };
return allowUpstream()
? { source: 'UPSTREAM', payload: null }
: { source: 'RATE_LIMITED', payload: null };
}
/** Return only last-good Overpass data, regardless of its age. */
async function readStaleOverpass(cacheKey) {
const cached = _overpassCache.get(cacheKey);
return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity);
}
/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */
const ROUTE_CACHE_MS = 600000;
const _routeCache = new Map();
@ -2547,17 +2556,6 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
res.end(payload.body || '');
}
/**
* Try each Overpass upstream in order until one succeeds.
*
* Skips rate-limited or 5xx responses and falls through to the next
* mirror. If all mirrors fail, returns the last rate-limited payload
* (if any) or throws the last error.
*
* @param {string} body - URL-encoded Overpass QL query body.
* @param {number} [maxResponseBytes] Endpoint-specific response cap.
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
*/
/**
* True only for an upstream response that is actually Overpass data.
*
@ -2575,6 +2573,15 @@ export function overpassPayloadIsData(payload) {
&& !payload.runtimeError;
}
/**
* Try each mirror once, retaining response-size and per-mirror timeout caps.
* Refusals and body-level failures rotate; total failure returns the last
* rate-limit payload, otherwise the first refusal, or throws a network error.
* @param {string} body URL-encoded Overpass QL query body.
* @param {number} [maxResponseBytes] Endpoint-specific response cap.
* @param {object} [options] Server-only endpoint and I/O overrides for tests.
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
*/
export async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, {
endpoints = OVERPASS_UPSTREAMS,
fetchImpl = fetch,
@ -2725,6 +2732,15 @@ function overpassProxy() {
return;
}
if (preflight.source !== 'UPSTREAM') {
// A coalesced caller sees the same failure as the original request
// and must get the same last-good fallback, not the raw refusal.
if (!overpassPayloadIsData(preflight.payload)) {
const stale = await readStaleOverpass(cacheKey);
if (stale) {
sendOverpassResponse(res, stale, 'STALE');
return;
}
}
if (preflight.source === 'DISK') {
_overpassCache.set(cacheKey, preflight.payload);
trimOverpassCache();
@ -2766,7 +2782,7 @@ function overpassProxy() {
// error): last-good roads beat an empty layer — serve stale from
// memory or disk at ANY age before surfacing the failure.
if (!overpassPayloadIsData(payload)) {
const stale = _overpassCache.get(cacheKey) || await readOverpassDisk(cacheKey, Infinity);
const stale = await readStaleOverpass(cacheKey);
if (stale) {
sendOverpassResponse(res, stale, 'STALE');
return;
@ -2776,7 +2792,7 @@ function overpassProxy() {
} catch (e) {
// Every mirror threw (network-level). Same serve-stale rule.
const stale = cacheKey
? (_overpassCache.get(cacheKey) || await readOverpassDisk(cacheKey, Infinity).catch(() => null))
? await readStaleOverpass(cacheKey)
: null;
if (stale) {
sendOverpassResponse(res, stale, 'STALE');