mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Both disk caches fed by OVERPASS_UPSTREAMS key only on the query — the Overpass proxy on the sanitized request body, the installations proxy on the bbox — so an entry written against one instance is a hit for the same query against another. After an operator repoints OVERPASS_UPSTREAMS, the previous upstream's answers keep being served for 7 days, 30 for boundary-class queries, and indefinitely through the serve-stale path. An install that predates #742 has a cache written from the public instances that were the default then, and configuring your own instance does not displace it. Cached answers now carry a tag identifying the configured upstream set, and a disk read refuses an entry that does not match it, on the stale path too. With nothing configured any entry is still accepted: there is no instance to contradict, and serving last-good data at any age is what the not-configured path exists for. The tag is a hash of the resolved endpoint list, never the URLs; it is written to disk and compared on read, and is not sent to clients or surfaced in errors. One line is logged once per process when a foreign entry is ignored, naming the variable and what will happen, as local-receivers.js already does for a rejected feed. Cost worth naming: on upgrade every existing entry is untagged, so the first outage after upgrading serves nothing where it previously served stale data, until each query is fetched once from the configured instance.
211 lines
6.8 KiB
JavaScript
211 lines
6.8 KiB
JavaScript
import {
|
|
OVERPASS_MAX_RESPONSE_BYTES,
|
|
resolveOverpassUpstreams,
|
|
resolveOverpassUpstreamTag,
|
|
OVERPASS_USER_AGENT,
|
|
OVERPASS_TIMEOUT_MS,
|
|
} from './constants.js';
|
|
import { readResponseTextCapped } from '../common/http.js';
|
|
import { simplifyOverpassPayloadBody } from './geometry.js';
|
|
|
|
/**
|
|
* Detect whether an Overpass API response body indicates rate-limiting.
|
|
*
|
|
* Checks for known rate-limit phrases in the body text regardless of
|
|
* HTTP status code, since some mirrors return 200 with an error payload.
|
|
*
|
|
* @param {string} bodyText - Upstream response body.
|
|
* @returns {boolean} True if the body looks rate-limited.
|
|
*/
|
|
function overpassLooksRateLimited(bodyText) {
|
|
const text = String(bodyText || '').toLowerCase();
|
|
return (
|
|
text.includes('rate_limited') ||
|
|
text.includes('quota of your ip address') ||
|
|
text.includes('dispatcher_client::request_read_and_idx::rate_limited') ||
|
|
text.includes('too many requests')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect an Overpass HTTP-200 body that is actually a runtime FAILURE (server-side
|
|
* timeout / out-of-memory) via its `remark`. These are transient upstream failures,
|
|
* not authoritative empty results, so they must not be returned or cached.
|
|
*/
|
|
function overpassLooksRuntimeError(bodyText) {
|
|
const text = String(bodyText || '').toLowerCase();
|
|
return (
|
|
text.includes('runtime error') ||
|
|
text.includes('timed out') ||
|
|
text.includes('out of memory')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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}
|
|
*/
|
|
function overpassPayloadIsData(payload) {
|
|
const status = Number(payload?.status);
|
|
return (
|
|
Number.isFinite(status) &&
|
|
status >= 200 &&
|
|
status < 300 &&
|
|
!payload.rateLimited &&
|
|
!payload.runtimeError
|
|
);
|
|
}
|
|
|
|
const cooldowns = new Map();
|
|
|
|
/** A stable, non-retryable capability response shared by every Overpass route. */
|
|
export function overpassNotConfigured() {
|
|
return {
|
|
status: 503,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: 'Detailed OpenStreetMap queries are not configured',
|
|
code: 'OVERPASS_NOT_CONFIGURED',
|
|
retryable: false,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Honor Retry-After dates/seconds; absent values use bounded exponential backoff. */
|
|
function retryDelay(value, failures, now) {
|
|
const seconds = Number(value);
|
|
const explicit =
|
|
value && Number.isFinite(seconds)
|
|
? seconds * 1000
|
|
: Date.parse(value) - now;
|
|
return Number.isFinite(explicit)
|
|
? Math.max(1000, explicit)
|
|
: Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
|
|
}
|
|
|
|
function refusal(status, retryAfterMs) {
|
|
return {
|
|
status,
|
|
contentType: 'application/json',
|
|
rateLimited: status === 429 || status === 406,
|
|
retryAfterMs,
|
|
body: JSON.stringify({
|
|
error: 'Configured Overpass upstream unavailable',
|
|
code: 'OVERPASS_UNAVAILABLE',
|
|
retryable: true,
|
|
retryAfterMs,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Query only the configured chain with capped reads, timeouts and per-endpoint cooldowns.
|
|
* Explicit endpoint/I/O overrides are server-only test seams. Empty data is valid.
|
|
*/
|
|
async function fetchOverpassPayload(
|
|
body,
|
|
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
|
|
{
|
|
endpoints = resolveOverpassUpstreams(),
|
|
fetchImpl = fetch,
|
|
readBody = readResponseTextCapped,
|
|
simplify = simplifyOverpassPayloadBody,
|
|
now = Date.now,
|
|
} = {},
|
|
) {
|
|
if (!endpoints.length) return overpassNotConfigured();
|
|
let failure = refusal(502, 30_000);
|
|
for (const endpoint of endpoints) {
|
|
const previous = cooldowns.get(endpoint);
|
|
if (previous?.until > now()) {
|
|
failure = refusal(previous.status, previous.until - now());
|
|
continue;
|
|
}
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
|
try {
|
|
const requestUrl = new URL(endpoint);
|
|
const authorization =
|
|
requestUrl.username || requestUrl.password
|
|
? 'Basic ' +
|
|
Buffer.from(
|
|
`${decodeURIComponent(requestUrl.username)}:${decodeURIComponent(requestUrl.password)}`,
|
|
).toString('base64')
|
|
: null;
|
|
requestUrl.username = '';
|
|
requestUrl.password = '';
|
|
const upstream = await fetchImpl(requestUrl.href, {
|
|
method: 'POST',
|
|
redirect: 'error',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': OVERPASS_USER_AGENT,
|
|
...(authorization ? { Authorization: authorization } : {}),
|
|
},
|
|
body,
|
|
signal: controller.signal,
|
|
});
|
|
const responseBody = await readBody(upstream, maxResponseBytes);
|
|
const rateLimited =
|
|
upstream.status === 429 ||
|
|
upstream.status === 406 ||
|
|
overpassLooksRateLimited(responseBody);
|
|
if (
|
|
rateLimited ||
|
|
upstream.status < 200 ||
|
|
upstream.status >= 300 ||
|
|
overpassLooksRuntimeError(responseBody)
|
|
) {
|
|
const failures = (previous?.failures || 0) + 1;
|
|
const delay = retryDelay(
|
|
upstream.headers.get('retry-after'),
|
|
failures - 1,
|
|
now(),
|
|
);
|
|
const status = rateLimited
|
|
? upstream.status === 406
|
|
? 406
|
|
: 429
|
|
: 502;
|
|
cooldowns.set(endpoint, { until: now() + delay, failures, status });
|
|
while (cooldowns.size > 64)
|
|
cooldowns.delete(cooldowns.keys().next().value);
|
|
failure = refusal(status, delay);
|
|
continue;
|
|
}
|
|
const parsed = JSON.parse(responseBody);
|
|
if (!Array.isArray(parsed?.elements) || parsed.remark)
|
|
throw new Error('Malformed Overpass response');
|
|
cooldowns.delete(endpoint);
|
|
return {
|
|
status: upstream.status,
|
|
body: simplify(responseBody),
|
|
contentType: 'application/json',
|
|
// Never retain a secret-bearing endpoint in cache or response metadata.
|
|
endpoint: 'configured',
|
|
// Which configured instance produced this, as a hash rather than a URL,
|
|
// so a cached answer is not reused after the operator repoints.
|
|
upstreams: resolveOverpassUpstreamTag(),
|
|
rateLimited: false,
|
|
};
|
|
} catch {
|
|
const failures = (previous?.failures || 0) + 1;
|
|
const delay = retryDelay(null, failures - 1, now());
|
|
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
|
|
while (cooldowns.size > 64)
|
|
cooldowns.delete(cooldowns.keys().next().value);
|
|
failure = refusal(502, delay);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
return failure;
|
|
}
|
|
|
|
export { overpassPayloadIsData, fetchOverpassPayload };
|