mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(terrain): key the 429 pause on status, never shorten it, and document it
Review follow-ups: the upstream error now carries its HTTP status and the pause checks error.status instead of the message text; concurrent 429s extend the pause with Math.max rather than a shorter Retry-After cutting a longer one short; the per-chunk re-check says it is for multi-chunk requests; the first 429 test runs its backoff on mocked timers. CURRENT-STATE describes the pause. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3ac986882c
commit
923211c24a
4 files changed
+30
-9
No files matched your search
@@ -3005,7 +3005,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> usable per point, while an uncached absent height still returns 502 rather
|
> usable per point, while an uncached absent height still returns 502 rather
|
||||||
> than becoming a fabricated ground value. A position the upstream answers
|
> than becoming a fabricated ground value. A position the upstream answers
|
||||||
> with a null ellipsoid is reported as an absent height rather than a refresh
|
> with a null ellipsoid is reported as an absent height rather than a refresh
|
||||||
> failure, and is left uncached so a later poll re-asks it. Client geoid
|
> failure, and is left uncached so a later poll re-asks it. After a 429 that
|
||||||
|
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||||
|
> (60 seconds when none, at most 10 minutes): cached and stale points are still
|
||||||
|
> served, uncached points get 502 and use the page's geoid fallback, and the
|
||||||
|
> pause is logged once by the request that started it. Client geoid
|
||||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||||
> camera batches still wait for their sequential requests, and unresolved
|
> camera batches still wait for their sequential requests, and unresolved
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export function terrainHeightsProxy(options = {}) {
|
|||||||
// Rate-limited: asking again only extends the limit. Cached points are
|
// Rate-limited: asking again only extends the limit. Cached points are
|
||||||
// still served; the rest resolve on the page through its geoid.
|
// still served; the rest resolve on the page through its geoid.
|
||||||
const error = new Error('HTTP 429');
|
const error = new Error('HTTP 429');
|
||||||
|
error.status = 429;
|
||||||
error.coolingDown = true;
|
error.coolingDown = true;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -233,18 +234,23 @@ export function terrainHeightsProxy(options = {}) {
|
|||||||
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
||||||
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
||||||
let chunkResults = [];
|
let chunkResults = [];
|
||||||
|
// Re-checked per chunk for when a request spans several chunks again;
|
||||||
|
// today requests are capped at one chunk (MAX_POINTS).
|
||||||
if (Date.now() >= rateLimitedUntil) {
|
if (Date.now() >= rateLimitedUntil) {
|
||||||
try {
|
try {
|
||||||
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
firstError = firstError || error;
|
firstError = firstError || error;
|
||||||
if (error?.message === 'HTTP 429')
|
// Concurrent 429s extend the pause, never cut it short.
|
||||||
rateLimitedUntil =
|
if (error?.status === 429)
|
||||||
|
rateLimitedUntil = Math.max(
|
||||||
|
rateLimitedUntil,
|
||||||
Date.now() +
|
Date.now() +
|
||||||
Math.min(
|
Math.min(
|
||||||
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
|
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
|
||||||
RATE_LIMIT_PAUSE_MAX_MS,
|
RATE_LIMIT_PAUSE_MAX_MS,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep later chunks aligned even if a malformed upstream response omits
|
// Keep later chunks aligned even if a malformed upstream response omits
|
||||||
@@ -322,7 +328,7 @@ export function terrainHeightsProxy(options = {}) {
|
|||||||
// Logged once, when the pause began.
|
// Logged once, when the pause began.
|
||||||
} else if (outcome.upstreamError) {
|
} else if (outcome.upstreamError) {
|
||||||
const pause =
|
const pause =
|
||||||
outcome.upstreamError.message === 'HTTP 429' &&
|
outcome.upstreamError.status === 429 &&
|
||||||
Date.now() < rateLimitedUntil
|
Date.now() < rateLimitedUntil
|
||||||
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
|
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export async function fetchTerrainChunkWithRetry(
|
|||||||
const res = await fetchImpl(url, signal ? { signal } : {});
|
const res = await fetchImpl(url, signal ? { signal } : {});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = new Error(`HTTP ${res.status}`);
|
const error = new Error(`HTTP ${res.status}`);
|
||||||
|
error.status = res.status;
|
||||||
error.retryable = res.status === 429 || res.status >= 500;
|
error.retryable = res.status === 429 || res.status >= 500;
|
||||||
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -421,8 +421,18 @@ test('after a 429 the terrain proxy leaves the upstream alone and logs once', as
|
|||||||
calls += 1;
|
calls += 1;
|
||||||
return new Response('', { status: 429 });
|
return new Response('', { status: 429 });
|
||||||
});
|
});
|
||||||
|
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||||
const request = install(terrainHeightsProxy());
|
const request = install(terrainHeightsProxy());
|
||||||
await request('/?points=1,2');
|
// The retries' backoff runs on mocked time.
|
||||||
|
let settled = false;
|
||||||
|
const first = request('/?points=1,2').then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
t.mock.timers.tick(1_000);
|
||||||
|
}
|
||||||
|
await first;
|
||||||
const afterFirst = calls;
|
const afterFirst = calls;
|
||||||
assert.ok(afterFirst >= 1);
|
assert.ok(afterFirst >= 1);
|
||||||
assert.equal(warnings.length, 1);
|
assert.equal(warnings.length, 1);
|
||||||
|
|||||||
Reference in new issue
Block a user