Files
gods-eye-view/server/providers/overpass/transport.js
T
Bilawal SidhuandCyber Knight fb5ee5aa36 fix(installations): send an identifying User-Agent to Overpass (#570)
Supersedes #420 by @GladiatorrX9, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: Cyber Knight <[email protected]>
2026-09-14 21:11:32 -05:00

159 lines
5.5 KiB
JavaScript

import {
OVERPASS_MAX_RESPONSE_BYTES,
OVERPASS_UPSTREAMS,
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
);
}
/**
* 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}>}
*/
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 endpoints) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
try {
const upstream = await fetchImpl(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': OVERPASS_USER_AGENT,
},
body,
signal: controller.signal,
});
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);
const runtimeError = overpassLooksRuntimeError(responseBody);
const payload = {
status,
body: responseBody,
contentType,
endpoint,
rateLimited,
runtimeError,
};
if (rateLimited) {
lastRateLimitPayload = payload;
continue;
}
// A 200 body carrying a runtime error / timeout is a transient upstream
// failure — skip to the next mirror rather than returning or caching it.
if (runtimeError) {
lastError = new Error(`Overpass runtime error (${endpoint})`);
continue;
}
// 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: a mirror refusing this client answers 406 while the others
// answer 200 to the very same request, so every Overpass-backed layer
// failed on an error page with 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 = simplify(payload.body);
return payload;
} catch (error) {
lastError = error;
} finally {
clearTimeout(timeoutId);
}
}
if (lastRateLimitPayload) return lastRateLimitPayload;
if (lastRefusalPayload) return lastRefusalPayload;
throw lastError || new Error('All Overpass upstreams failed');
}
export { overpassPayloadIsData, fetchOverpassPayload };