mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
A Retry-After longer than the 10 s retry budget used to sleep the whole budget out and then give up, so the proxy's 429 pause started 10 s late and every new-point poll in that window still went upstream and logged. The retry loop now stops at once in that case. Tests: the retry loop no longer sleeps for an unreachable Retry-After; fully cached points, including stale ones, are served during the pause with no upstream call; a 503 does not pause; a shorter concurrent Retry-After never cuts the pause short. CURRENT-STATE now says which requests are still served and which log.
293 lines
9.6 KiB
JavaScript
293 lines
9.6 KiB
JavaScript
// Pure server-side mechanics for the Re:Earth terrain-heights proxy.
|
|
// Kept free of Vite/Node middleware state so cache reconstruction and retry
|
|
// behavior can be exercised by the offline node:test suite.
|
|
|
|
/** Decimal precision used by the terrain client when serializing lon/lat. */
|
|
export const TERRAIN_POINT_PRECISION = 5;
|
|
|
|
/** Maximum time retries may add after the first upstream attempt settles. */
|
|
export const TERRAIN_RETRY_BUDGET_MS = 10_000;
|
|
|
|
/** One initial attempt plus three bounded retries. */
|
|
export const TERRAIN_MAX_ATTEMPTS = 4;
|
|
|
|
/** @param {number} ms */
|
|
function defaultSleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Parse the proxy's `points=lon,lat;...` query parameter.
|
|
* @param {string|null|undefined} raw
|
|
* @returns {Array<[number, number]>|null}
|
|
*/
|
|
export function parseTerrainPoints(raw) {
|
|
const text = String(raw || '').trim();
|
|
if (!text) return null;
|
|
const pairs = text
|
|
.split(';')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
if (pairs.length === 0) return null;
|
|
const points = [];
|
|
for (const pair of pairs) {
|
|
const parts = pair.split(',');
|
|
if (parts.length !== 2) return null;
|
|
const lon = Number(parts[0]);
|
|
const lat = Number(parts[1]);
|
|
if (
|
|
!Number.isFinite(lon) ||
|
|
!Number.isFinite(lat) ||
|
|
Math.abs(lon) > 180 ||
|
|
Math.abs(lat) > 90
|
|
)
|
|
return null;
|
|
points.push([lon, lat]);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
/**
|
|
* Canonical per-point cache key, matching the client's 5dp request format.
|
|
* @param {[number, number]} point lon/lat pair
|
|
*/
|
|
export function terrainPointKey([lon, lat]) {
|
|
return `${lon.toFixed(TERRAIN_POINT_PRECISION)},${lat.toFixed(TERRAIN_POINT_PRECISION)}`;
|
|
}
|
|
|
|
/** @param {[number, number]} point */
|
|
function canonicalTerrainPoint(point) {
|
|
const key = terrainPointKey(point);
|
|
return { key, point: key.split(',').map(Number) };
|
|
}
|
|
|
|
/** Only a real numeric ellipsoid height is cacheable/servable. */
|
|
export function validTerrainResult(result) {
|
|
return Boolean(result) && Number.isFinite(result.ellipsoid);
|
|
}
|
|
|
|
/**
|
|
* A row the upstream DID return for its position, but with no usable height:
|
|
* Re:Earth sporadically answers with a real `elevation` and a null
|
|
* `geoid`/`ellipsoid`. Measured at ~0.16% of points, and transient — the same
|
|
* coordinate resolves on a later poll. That is an absent datum, not a failed
|
|
* refresh, so the point is left uncached to be re-asked while the client
|
|
* covers it through its bundled geoid. A null/undefined entry is NOT this
|
|
* case: it means the position was dropped from the response, which is a
|
|
* genuine upstream fault.
|
|
* @param {unknown} result
|
|
*/
|
|
export function absentTerrainDatum(result) {
|
|
return (
|
|
typeof result === 'object' &&
|
|
result !== null &&
|
|
!Array.isArray(result) &&
|
|
result.ellipsoid === null &&
|
|
result.geoid === null &&
|
|
Number.isFinite(result.elevation)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert Retry-After (delta-seconds or HTTP-date) to milliseconds.
|
|
* @param {string|null|undefined} value
|
|
* @param {number} nowMs
|
|
* @returns {number|null}
|
|
*/
|
|
export function terrainRetryAfterMs(value, nowMs = Date.now()) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) return null;
|
|
const seconds = Number(text);
|
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
const dateMs = Date.parse(text);
|
|
if (!Number.isFinite(dateMs)) return null;
|
|
return Math.max(0, dateMs - nowMs);
|
|
}
|
|
|
|
/**
|
|
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
|
|
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
|
|
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
|
|
* Retry-After longer than the budget left ends the retries at once: sleeping
|
|
* out the budget cannot reach the time the upstream asked for, and it would
|
|
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
|
|
*
|
|
* Dependencies are injectable for deterministic offline tests.
|
|
* @param {Array<[number, number]>} points
|
|
* @param {object} [options]
|
|
* @returns {Promise<Array<object>>}
|
|
*/
|
|
export async function fetchTerrainChunkWithRetry(
|
|
points,
|
|
{
|
|
fetchImpl = globalThis.fetch,
|
|
sleep = defaultSleep,
|
|
random = Math.random,
|
|
now = Date.now,
|
|
makeSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs),
|
|
attemptTimeoutMs = 30_000,
|
|
retryBudgetMs = TERRAIN_RETRY_BUDGET_MS,
|
|
maxAttempts = TERRAIN_MAX_ATTEMPTS,
|
|
} = {},
|
|
) {
|
|
const pointsParam = points.map(terrainPointKey).join(';');
|
|
const url = `https://terrain.reearth.land/heights.json?points=${encodeURIComponent(pointsParam)}`;
|
|
let retryStartedAt = null;
|
|
let lastError = null;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
let timeoutMs = attemptTimeoutMs;
|
|
if (attempt > 0) {
|
|
const remaining = retryBudgetMs - (now() - retryStartedAt);
|
|
if (remaining <= 0) break;
|
|
timeoutMs = Math.max(1, Math.min(attemptTimeoutMs, remaining));
|
|
}
|
|
|
|
try {
|
|
const signal = makeSignal(timeoutMs);
|
|
const res = await fetchImpl(url, signal ? { signal } : {});
|
|
if (!res.ok) {
|
|
const error = new Error(`HTTP ${res.status}`);
|
|
error.status = res.status;
|
|
error.retryable = res.status === 429 || res.status >= 500;
|
|
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
|
throw error;
|
|
}
|
|
const json = await res.json();
|
|
if (!Array.isArray(json?.results)) {
|
|
const error = new Error(
|
|
'malformed upstream response (no results array)',
|
|
);
|
|
error.retryable = false;
|
|
throw error;
|
|
}
|
|
return json.results;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const retryable = error?.retryable !== false;
|
|
if (!retryable || attempt >= maxAttempts - 1) break;
|
|
if (retryStartedAt == null) retryStartedAt = now();
|
|
const remaining = retryBudgetMs - (now() - retryStartedAt);
|
|
if (remaining <= 0) break;
|
|
|
|
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
|
|
if (retryAfterMs != null && retryAfterMs > remaining) break;
|
|
const backoffMs = Math.round(
|
|
350 * 2 ** attempt * (0.75 + random() * 0.5),
|
|
);
|
|
const requestedDelay = retryAfterMs ?? backoffMs;
|
|
const delayMs = Math.min(requestedDelay, remaining);
|
|
if (delayMs > 0) await sleep(delayMs);
|
|
}
|
|
}
|
|
|
|
throw (
|
|
lastError || new Error('terrain heights upstream retry budget exhausted')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve a requested point batch against a per-point cache and a missing-only
|
|
* fetcher. Results are reconstructed by canonical key in exact request order,
|
|
* including duplicates. Stale values remain eligible only when refresh fails.
|
|
* If any position has no real height, return 502 with no fabricated result.
|
|
*
|
|
* @param {object} options
|
|
* @param {Array<[number, number]>} options.points
|
|
* @param {Map<string, {at:number, result:object}>} options.cache
|
|
* @param {(points:Array<[number, number]>)=>Promise<Array<object>>} options.fetchMissing
|
|
* @param {number} options.ttlMs
|
|
* @param {()=>number} [options.now]
|
|
*/
|
|
export async function resolveTerrainHeightRequest({
|
|
points,
|
|
cache,
|
|
fetchMissing,
|
|
ttlMs,
|
|
now = Date.now,
|
|
}) {
|
|
const requested = points.map(canonicalTerrainPoint);
|
|
const unique = new Map();
|
|
for (const item of requested) {
|
|
if (!unique.has(item.key)) unique.set(item.key, item.point);
|
|
}
|
|
|
|
const requestStartedAt = now();
|
|
const missing = [];
|
|
for (const [key, point] of unique) {
|
|
const entry = cache.get(key);
|
|
if (
|
|
entry &&
|
|
validTerrainResult(entry.result) &&
|
|
requestStartedAt - entry.at < ttlMs
|
|
)
|
|
continue;
|
|
missing.push({ key, point });
|
|
}
|
|
|
|
let cacheChanged = false;
|
|
let upstreamError = null;
|
|
let absentPoints = 0;
|
|
if (missing.length > 0) {
|
|
try {
|
|
const fetched = await fetchMissing(missing.map((item) => item.point));
|
|
if (!Array.isArray(fetched))
|
|
throw new Error('malformed upstream response (no results array)');
|
|
const fetchedAt = now();
|
|
let omittedPositions = 0;
|
|
for (let i = 0; i < missing.length; i += 1) {
|
|
const result = fetched[i];
|
|
if (validTerrainResult(result)) {
|
|
// Re-insert rather than overwrite: a Map keeps an existing key in
|
|
// its old position, and the proxy evicts from the front.
|
|
cache.delete(missing[i].key);
|
|
cache.set(missing[i].key, { at: fetchedAt, result });
|
|
cacheChanged = true;
|
|
} else if (absentTerrainDatum(result)) {
|
|
// Upstream answered but had no height. Transient, so deliberately
|
|
// left uncached: the next poll re-asks and usually succeeds.
|
|
absentPoints += 1;
|
|
} else {
|
|
// Missing or malformed positions remain upstream faults.
|
|
omittedPositions += 1;
|
|
}
|
|
}
|
|
if (omittedPositions > 0 || fetched.length !== missing.length) {
|
|
const dropped =
|
|
omittedPositions || Math.abs(fetched.length - missing.length);
|
|
upstreamError = new Error(
|
|
`upstream omitted ${dropped} terrain position(s)`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
upstreamError = error;
|
|
}
|
|
}
|
|
|
|
const results = requested.map(({ key }) => {
|
|
const entry = cache.get(key);
|
|
return entry && validTerrainResult(entry.result) ? entry.result : null;
|
|
});
|
|
if (results.some((result) => result == null)) {
|
|
return {
|
|
status: 502,
|
|
body: {
|
|
error:
|
|
'terrain heights fetch failed and no cache available for every point',
|
|
},
|
|
cacheChanged,
|
|
upstreamError,
|
|
absentPoints,
|
|
requestedPoints: unique.size,
|
|
};
|
|
}
|
|
return {
|
|
status: 200,
|
|
body: { results },
|
|
cacheChanged,
|
|
upstreamError,
|
|
absentPoints,
|
|
requestedPoints: unique.size,
|
|
};
|
|
}
|