fix(space): respect provider cooldowns and bound TLE groups

This commit is contained in:
Said committed 2026-10-20 00:28:00 +01:00
1 parent de2138e84c
commit 9d3d5164fe
7 files changed
+196 -16

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
CelesTrak accepts only the seven satellite groups used by the app and gates
requests before provider work. CelesTrak and Launch Library failures start a
provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds
through 30 minutes, with a 60-second fallback. During cooldown dated last-good
data is served with stale provenance, otherwise the proxy returns a bounded
error without another upstream attempt. Error bodies are cancelled before reading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
+3
View File
@@ -96,6 +96,9 @@
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/space/cooldown.js",
"server/providers/space/launch-library.js",
"server/providers/common/http.js",
"src/data/spaceProviderRequests.js",
+50 -6
View File
@@ -1,6 +1,8 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import { providerCooldownMs } from './cooldown.js';
import { admitSameSite } from '../common/same-site.js';
import { readResponseTextCapped } from '../common/http.js';
/**
@@ -24,6 +26,16 @@ import { readResponseTextCapped } from '../common/http.js';
export function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const groups = new Set([
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
]);
let cooldownUntil = 0;
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
@@ -64,7 +76,11 @@ export function celestrakProxy() {
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
cooldownUntil = Date.now() + providerCooldownMs(res);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
@@ -80,12 +96,18 @@ export function celestrakProxy() {
const group = String(req.url || '')
.replace(/^\//, '')
.split('?')[0];
if (!/^[a-z0-9-]+$/i.test(group)) {
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
res.writeHead(405);
res.end();
return;
}
if (!groups.has(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
}
const send = (status, body, cacheStatus) => {
const send = (status, body, cacheStatus, entry) => {
// Guard against a double-send (e.g. a throw AFTER a response already
// went out routing into the catch's send): writeHead after headersSent
// throws "Cannot set headers after they are sent".
@@ -93,6 +115,17 @@ export function celestrakProxy() {
res.writeHead(status, {
'Content-Type': 'text/plain',
'x-tle-cache': cacheStatus,
'Cache-Control': 'no-store',
...(entry
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
: {}),
...(cooldownUntil > 0 && Date.now() < cooldownUntil
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
),
}
: {}),
});
res.end(body);
};
@@ -104,7 +137,16 @@ export function celestrakProxy() {
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
send(200, entry.body, 'HIT');
send(200, entry.body, 'HIT', entry);
return;
}
if (Date.now() < cooldownUntil) {
send(
entry ? 200 : 502,
entry?.body || 'celestrak upstream cooling down',
entry ? 'STALE-ERROR' : 'NONE',
entry,
);
return;
}
// Stale or missing → refresh, single-flight per group.
@@ -118,6 +160,8 @@ export function celestrakProxy() {
return fresh;
})
.catch((err) => {
if (Date.now() >= cooldownUntil)
cooldownUntil = Date.now() + 60000;
console.warn(
'[celestrak-proxy] refresh failed — serving cache if any',
);
@@ -128,9 +172,9 @@ export function celestrakProxy() {
}
const fresh = await inflight.get(group);
if (fresh) {
send(200, fresh.body, 'MISS');
send(200, fresh.body, 'MISS', fresh);
} else if (entry) {
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty
} else {
send(502, 'celestrak fetch failed and no cache available', 'NONE');
}
+12
View File
@@ -0,0 +1,12 @@
/** Bound an upstream Retry-After delay (seconds or HTTP date). */
export function providerCooldownMs(response, now = Date.now()) {
const raw = response?.headers?.get('retry-after')?.trim();
const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN;
const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN;
const delay = Number.isFinite(seconds)
? seconds * 1000
: Number.isFinite(deadline)
? deadline - now
: 60000;
return Math.min(30 * 60000, Math.max(5000, delay));
}
+34 -2
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import { providerCooldownMs } from './cooldown.js';
import { promises as fsp } from 'node:fs';
import {
readResponseTextCapped,
@@ -30,6 +31,8 @@ export function rocketLaunchesProxy() {
'launch-library-2-v2.3.json',
);
let cache = null;
let cooldownUntil = 0;
let cooldownStatus = 502;
let diskLoaded = false;
const inFlight = new Map();
@@ -62,7 +65,18 @@ export function rocketLaunchesProxy() {
function send(res, status, body, cacheState) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store',
'Cache-Control':
status === 200 && cacheState !== 'STALE-ERROR'
? 'public, max-age=900'
: 'no-store',
...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}),
...(Date.now() < cooldownUntil
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
),
}
: {}),
'X-GEV-Cache': cacheState,
});
res.end(body);
@@ -75,17 +89,21 @@ export function rocketLaunchesProxy() {
signal: AbortSignal.timeout(20000),
headers: launchLibraryRequestHeaders(),
});
const body = await readResponseTextCapped(upstream, maxResponseBytes);
if (!upstream.ok) {
cooldownUntil = Date.now() + providerCooldownMs(upstream);
cooldownStatus = upstream.status;
void upstream.body?.cancel().catch(() => {});
const error = new Error(`upstream HTTP ${upstream.status}`);
error.upstreamStatus = upstream.status;
throw error;
}
const body = await readResponseTextCapped(upstream, maxResponseBytes);
const parsed = JSON.parse(body);
if (!Array.isArray(parsed?.results))
throw new Error('malformed upstream response');
const fresh = { at: Date.now(), body };
cache = fresh;
cooldownUntil = 0;
void saveDiskCache(fresh);
return fresh;
}
@@ -103,6 +121,16 @@ export function rocketLaunchesProxy() {
return;
}
const stale = cache;
if (now < cooldownUntil) {
send(
res,
stale ? 200 : cooldownStatus,
stale?.body ||
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
stale ? 'STALE-ERROR' : 'NONE',
);
return;
}
const request = coalesceProxyRequest(
inFlight,
'recent-launches',
@@ -112,6 +140,10 @@ export function rocketLaunchesProxy() {
const fresh = await request.promise;
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
} catch (error) {
if (Date.now() >= cooldownUntil) {
cooldownUntil = Date.now() + 60000;
cooldownStatus = 502;
}
// Log only a bounded status, never upstream bodies, URLs, or credentials.
const status = Number.isInteger(error?.upstreamStatus)
? error.upstreamStatus
+9 -7
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { providerCooldownMs } from '../server/providers/space/cooldown.js';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
@@ -23,7 +24,8 @@ function extract(name) {
function fixture(name, overrides = {}, preview = false) {
const logs = [];
const deps = {
readResponseTextCapped, coalesceProxyRequest,
readResponseTextCapped, coalesceProxyRequest, providerCooldownMs,
admitSameSite: () => false,
// 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,
@@ -117,7 +119,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met
test('CelesTrak unexpected failures hide details', async () => {
const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } });
const res = await app.request('/active');
const res = await app.request('/stations');
assert.equal(res.status, 500);
assert.equal(res.body, 'celestrak proxy error');
assert.equal(res.headers['x-tle-cache'], 'ERROR');
@@ -126,7 +128,7 @@ test('CelesTrak unexpected failures hide details', async () => {
test('CelesTrak retains invalid-group and unavailable responses', async () => {
const app = fixture('celestrakProxy');
assert.equal((await app.request('/../')).status, 400);
const res = await app.request('/active');
const res = await app.request('/stations');
assert.equal(res.status, 502);
assert.equal(res.headers['x-tle-cache'], 'NONE');
});
@@ -135,13 +137,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => {
let now = Date.now();
let calls = 0;
const app = fixture('celestrakProxy', {
Date: { now: () => now },
Date: class extends Date { static now() { return now; } },
fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); },
});
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS');
assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT');
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS');
assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT');
now += 7 * 3600_000;
const stale = await app.request('/active');
const stale = await app.request('/stations');
assert.equal(stale.status, 200);
assert.equal(stale.body, '1 valid-fixture-TLE');
assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR');
+81 -1
View File
@@ -161,7 +161,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async
assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap');
assert.equal(fsp.writeFile.mock.callCount(), 0);
assert.equal((await request('/api/celestrak', '/stations')).status, 502);
assert.equal(bodies.length, 2, 'nothing was cached in memory');
assert.equal(
bodies.length,
1,
'failed bodies are not cached but a cooldown bounds retry work',
);
});
for (const preview of [false, true])
@@ -190,3 +194,79 @@ for (const preview of [false, true])
assert.equal(hit.body, first.body);
assert.equal(calls, 1);
});
test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => {
isolateDisk(t);
t.mock.method(console, 'warn', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['tle', 'launches']) {
for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) {
let calls = 0;
let fail = false;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{
status: kind === 'tle' ? 403 : 429,
headers: { 'Retry-After': retryAfter },
},
);
return new Response(
kind === 'tle'
? 'ISS\n1 25544U fixture\n2 25544 fixture\n'
: '{"results":[]}',
);
});
const request = install(
kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(),
);
const route = kind === 'tle' ? '/api/celestrak' : '/api/launches';
const url = kind === 'tle' ? '/stations' : '/';
if (kind === 'tle') {
assert.equal(
(await request(route, '/not-a-supported-group')).status,
400,
);
assert.equal(calls, 0);
}
const first = await request(route, url);
now += 7 * 3600000;
// Recompute the HTTP-date relative to the clock at the failed refresh.
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, 'fetch', async (...args) => {
const response = await originalFetch(...args);
if (fail && !/^\d+$/.test(retryAfter))
response.headers.set(
'Retry-After',
new Date(now + 90000).toUTCString(),
);
return response;
});
fail = true;
const stale = await request(route, url);
assert.equal(stale.status, 200);
assert.equal(stale.body, first.body);
assert.equal(stale.headers['Retry-After'], '90');
assert.ok(
stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'],
);
assert.equal(cancelled, true);
await request(route, url);
if (kind === 'tle')
assert.equal((await request(route, '/visual')).status, 502);
assert.equal(calls, 2, 'cooldown applies across TLE groups');
now += 91000;
fail = false;
assert.equal((await request(route, url)).status, 200);
assert.equal(calls, 3);
}
}
});