fix(server): bound the terrain-heights cache and its request rate

Every distinct 5-decimal point was a permanent in-memory entry, a
permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a
live upstream call. Nothing limited the rate, and coordinates were
checked for being finite rather than for being on Earth, so an
out-of-range pair was still a valid cache key.

Four bounds, each following a pattern this repo already uses:

- WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no
  later stage has to. Poles and the antimeridian still parse.
- The point cache holds at most 50,000 entries and evicts oldest-first,
  the shape `trackBackfillProxies` uses. Sized from measurement: this
  checkout's cache file held 6,189 entries in 1.0 MB of real use, so the
  ceiling is eight times that working set. Terrain does not move, so an
  evicted point costs one refetch and never a wrong answer.
- A cache file above 24 MB is not read back, mirroring
  `rocketLaunchesProxy`. An unmeasurable file is not treated as an
  oversized one: without stat, readFile decides, exactly as before.
- The route carries an always-on limiter of 90/min per client, 270
  global — the `/api/overpass` band, and an order of magnitude above
  measured demand (a full test:track run drove 10 requests, peak 8/min).

The ceilings are constructor options so they can be exercised as
behaviour rather than asserted as constants; production passes none.
They are destructured in the body because `proxyErrorResponses.test.mjs`
slices these proxies to the first column-zero closing brace.
This commit is contained in:
daikaginza committed 2026-09-27 22:10:43 +07:00
1 parent b210ab0fe4
commit be7187cea7
8 files changed
+360 -6

No files matched your search

+8
View File
@@ -18,6 +18,13 @@ function defaultSleep(ms) {
/**
* Parse the proxy's `points=lon,lat;...` query parameter.
*
* Finite is not the same as on Earth. A coordinate outside WGS-84 range can
* never resolve to a height, but it is still a distinct 5dp cache key, so
* accepting it meant an out-of-range point became a permanent cache entry and
* a live upstream call. Range is checked here, at the edge, so no later stage
* has to.
*
* @param {string|null|undefined} raw
* @returns {Array<[number, number]>|null}
*/
@@ -36,6 +43,7 @@ export function parseTerrainPoints(raw) {
const lon = Number(parts[0]);
const lat = Number(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
points.push([lon, lat]);
}
return points;
+4
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
@@ -22,6 +23,9 @@ function fixture(name, overrides = {}, preview = false) {
const logs = [];
const deps = {
readResponseTextCapped, coalesceProxyRequest,
// The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey,
path, process: { cwd: () => '/fixture', env: {} },
fsp: {
readFile: async () => { throw new Error('cache absent'); },
+232
View File
@@ -0,0 +1,232 @@
// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates.
//
// Every distinct 5dp lon/lat is its own cache key, its own permanent line in
// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things
// bound that: WGS-84 range at parse, an entry ceiling on the map, a size
// ceiling on the file that is read back, and a per-client request limiter.
//
// These cases drive the real exported route handler. `fetch` is stubbed per
// test and restored after, so a case that reaches upstream fails loudly.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { terrainHeightsProxy } from '../server/providers/terrain.js';
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(path, handler) {
routes.set(path, handler);
},
},
});
return routes;
}
function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) {
return new Promise((resolve, reject) => {
const headers = new Map();
const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } };
const res = {
statusCode: 200,
headersSent: false,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
writeHead(status, hdrs = {}) {
this.statusCode = status;
this.headersSent = true;
for (const [name, value] of Object.entries(hdrs))
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: parsed,
});
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
function stubFetch(t, impl) {
const original = globalThis.fetch;
globalThis.fetch = impl;
t.after(() => {
globalThis.fetch = original;
});
}
/**
* Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order.
* Records which point strings were actually asked for, so a test can assert on
* cache misses rather than on internal state.
*/
function stubTerrainUpstream(t) {
const asked = [];
stubFetch(t, async (url) => {
const points = decodeURIComponent(
new URL(String(url)).searchParams.get('points') || '',
)
.split(';')
.filter(Boolean);
asked.push(...points);
return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) });
});
return asked;
}
test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [
'0,91',
'0,-91',
'181,0',
'-181,0',
'1e9,1e9',
'0,90.00001',
])
assert.equal(parseTerrainPoints(raw), null, raw);
// The poles and the antimeridian are real places and must still parse.
for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26'])
assert.ok(parseTerrainPoints(raw), raw);
});
test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => {
let upstreamCalls = 0;
stubFetch(t, async () => {
upstreamCalls += 1;
return new Response('[]', { status: 200 });
});
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400);
assert.equal(
upstreamCalls,
0,
'an impossible coordinate must not cost an upstream call',
);
});
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
});
assert.equal(mixed.statusCode, 400);
});
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
'/api/terrain/heights',
);
const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1)
await request(route, { url: `/?points=${point(i)}` });
assert.equal(asked.length, 4, 'four distinct points, four upstream lookups');
// The newest point is still cached...
await request(route, { url: `/?points=${point(4)}` });
assert.equal(asked.length, 4, 'a cached point costs nothing');
// ...and the oldest was evicted, so it costs one refetch — never a wrong
// answer, because terrain does not move.
await request(route, { url: `/?points=${point(1)}` });
assert.equal(asked.length, 5, 'the evicted point was asked for again');
assert.equal(asked[4], point(1));
});
test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
// A cache file holding the answer, but larger than the ceiling allows.
const padded = {
version: 2,
points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } },
_pad: 'x'.repeat(4096),
};
await writeFile(cachePath, JSON.stringify(padded), 'utf8');
const route = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }),
).get('/api/terrain/heights');
await request(route, { url: `/?points=${point}` });
assert.deepEqual(
asked,
[point],
'the oversized file was skipped, so the point was a miss',
);
// The same file under a ceiling that admits it IS used, which is what proves
// the previous assertion came from the size guard and not a broken path.
const admitted = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }),
).get('/api/terrain/heights');
await request(admitted, { url: `/?points=${point}` });
assert.deepEqual(asked, [point], 'a cache within the ceiling is still read');
});
test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) {
// A different point each time: distinct keys are the growth path.
const lon = (-97.7 - i / 1000).toFixed(5);
const answer = await request(route, { url: `/?points=${lon},30.26` });
if (answer.statusCode === 429) {
refusedAt = i;
break;
}
}
assert.equal(refusedAt, 91, 'the 91st request in a minute is refused');
});
test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1)
await request(route, {
url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`,
remoteAddress: '198.51.100.7',
});
const exhausted = await request(route, {
url: '/?points=-99.5,30.26',
remoteAddress: '198.51.100.7',
});
assert.equal(exhausted.statusCode, 429);
const neighbour = await request(route, {
url: '/?points=-99.6,30.26',
remoteAddress: '198.51.100.8',
});
assert.notEqual(neighbour.statusCode, 429);
});