mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 12:54:01 +00:00
fix(satellites): a SATCAT failure no longer stops TLE refreshes
Both CelesTrak routes shared one cooldown, and any failure set it: one bad SATCAT answer made /api/celestrak return 502 "cooling down" for a minute, failing the Starlink load and stalling every TLE group past its cache. Throttling (403/429) still holds both routes off, since CelesTrak throttles the client; any other failure holds off only the route that failed.
This commit is contained in:
1 parent
d404445da3
commit
8ae56587c3
2 files changed
+53
-12
No files matched your search
@@ -29,8 +29,10 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* The same plugin serves CelesTrak SATCAT records for the satellites layer's
|
||||
* groups at `/api/celestrak-satcat/<group>` (object type, operational status
|
||||
* and owner, which file each object under a class). The catalog changes
|
||||
* slowly, so SATCAT is cached for a day; both routes share the upstream
|
||||
* cooldown, since CelesTrak throttles the client, not the endpoint.
|
||||
* slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not
|
||||
* the endpoint, so a 403 or 429 holds both routes off; any other failure
|
||||
* holds off only the route that failed, so a bad SATCAT answer never stops
|
||||
* TLE refreshes.
|
||||
*/
|
||||
export function celestrakProxy() {
|
||||
const TLE_TTL_MS = 6 * 3600_000;
|
||||
@@ -81,12 +83,17 @@ export function celestrakProxy() {
|
||||
invalid: 'no SATCAT header in response',
|
||||
},
|
||||
];
|
||||
let cooldownUntil = 0;
|
||||
// CelesTrak throttling (403/429) is per client, so it holds every feed
|
||||
// off; any other failure holds off only the feed that failed, so a bad
|
||||
// SATCAT answer never blocks the TLEs the layer cannot do without.
|
||||
let hostCooldownUntil = 0;
|
||||
|
||||
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
|
||||
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function createFeedHandler(feed) {
|
||||
let feedCooldownUntil = 0;
|
||||
const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil);
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group));
|
||||
@@ -126,10 +133,10 @@ export function celestrakProxy() {
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
cooldownUntil = Math.max(
|
||||
cooldownUntil,
|
||||
Date.now() + providerCooldownMs(res),
|
||||
);
|
||||
const until = Date.now() + providerCooldownMs(res);
|
||||
if (res.status === 403 || res.status === 429)
|
||||
hostCooldownUntil = Math.max(hostCooldownUntil, until);
|
||||
else feedCooldownUntil = Math.max(feedCooldownUntil, until);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
@@ -171,10 +178,10 @@ export function celestrakProxy() {
|
||||
...(entry
|
||||
? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() }
|
||||
: {}),
|
||||
...(cooldownUntil > 0 && Date.now() < cooldownUntil
|
||||
...(cooldownUntil() > 0 && Date.now() < cooldownUntil()
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((cooldownUntil - Date.now()) / 1000),
|
||||
Math.ceil((cooldownUntil() - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -192,7 +199,7 @@ export function celestrakProxy() {
|
||||
send(200, entry.body, 'HIT', entry);
|
||||
return;
|
||||
}
|
||||
if (Date.now() < cooldownUntil) {
|
||||
if (Date.now() < cooldownUntil()) {
|
||||
send(
|
||||
entry ? 200 : 502,
|
||||
entry?.body || 'celestrak upstream cooling down',
|
||||
@@ -212,8 +219,8 @@ export function celestrakProxy() {
|
||||
return fresh;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (Date.now() >= cooldownUntil)
|
||||
cooldownUntil = Date.now() + 60000;
|
||||
if (Date.now() >= feedCooldownUntil)
|
||||
feedCooldownUntil = Date.now() + 60000;
|
||||
console.warn(
|
||||
'[celestrak-proxy] refresh failed — serving cache if any',
|
||||
);
|
||||
|
||||
@@ -359,6 +359,40 @@ test('SATCAT is proxied per core group for a day, separately from TLEs, and serv
|
||||
assert.equal(stale.headers['x-satcat-cache'], 'STALE-ERROR');
|
||||
});
|
||||
|
||||
test('a SATCAT failure never holds off the TLEs, but CelesTrak throttling holds off both', async (t) => {
|
||||
isolateDisk(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const tle =
|
||||
'ISS (ZARYA)\n1 25544U 98067A 24001.00000000 .00016717 00000-0 10270-3 0 9001\n2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50000000 04\n';
|
||||
let satcatAnswer = () => new Response('<html>not csv</html>');
|
||||
const fetched = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const parsed = new URL(url);
|
||||
fetched.push(parsed.pathname);
|
||||
return parsed.pathname.startsWith('/satcat/')
|
||||
? satcatAnswer()
|
||||
: new Response(tle);
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
assert.equal((await request('/api/celestrak-satcat', '/sar')).status, 502);
|
||||
const starlink = await request('/api/celestrak', '/starlink');
|
||||
assert.equal(starlink.status, 200, 'the TLE feed is not cooling down');
|
||||
assert.equal(starlink.body, tle);
|
||||
|
||||
// A 429 is CelesTrak throttling this client: every feed waits it out.
|
||||
satcatAnswer = () =>
|
||||
new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
const throttled = install(celestrakProxy());
|
||||
await throttled('/api/celestrak-satcat', '/visual');
|
||||
const before = fetched.length;
|
||||
const held = await throttled('/api/celestrak', '/geo');
|
||||
assert.equal(held.status, 502);
|
||||
assert.equal(fetched.length, before, 'nothing went upstream');
|
||||
});
|
||||
|
||||
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
|
||||
isolateDisk(t);
|
||||
let calls = 0;
|
||||
|
||||
Reference in new issue
Block a user