fix(weather): wait out metered RainViewer tiles instead of caching holes

This commit is contained in:
Bilawal Sidhu committed 2026-09-28 20:46:58 -05:00
1 parent d496dd9f33
commit 9f3cdf8203
2 files changed
+53 -1

No files matched your search

+29 -1
View File
@@ -10,6 +10,19 @@ export const RAINVIEWER_BOUNDS = Object.freeze({
north: MERCATOR_LATITUDE,
});
const wait = (ms, signal) =>
new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
/** Clamp a non-wrapping geographic window to the Mercator domain. */
export function mercatorBbox(bbox = RAINVIEWER_BOUNDS) {
const box = { ...bbox };
@@ -92,6 +105,7 @@ export async function composeMercatorImage({
signal = new AbortController().signal,
maxTiles = 24,
concurrency = 6,
sleep = wait,
}) {
signal.throwIfAborted();
if (
@@ -131,7 +145,21 @@ export async function composeMercatorImage({
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', y);
const response = await fetchImpl(url, { signal });
let response = await fetchImpl(url, { signal });
// The proxy meters RainViewer; wait out a 429 rather than baking a hole
// into a frame that is then cached.
for (let retry = 0; response.status === 429 && retry < 3; retry++) {
await response.body?.cancel();
const seconds = Number(response.headers.get('retry-after'));
await sleep(
Math.min(
15,
Number.isFinite(seconds) && seconds > 0 ? seconds : 2,
) * 1000,
signal,
);
response = await fetchImpl(url, { signal });
}
if (!response.ok) throw new Error('Weather tile unavailable');
const bytes = await readResponseBytesCapped(response, 1024 * 1024);
signal.throwIfAborted();
@@ -150,6 +150,7 @@ test('mosaic limits concurrency, tolerates partial failure, releases decoded ima
composeMercatorImage({
...options,
fetchImpl: async () => new Response('', { status: 429 }),
sleep: async () => {},
}),
/tiles unavailable/,
);
@@ -218,3 +219,26 @@ test('whole and detail RainViewer images use composition and strict source metad
validateWeatherSnapshot({ ...snapshot, product: 'radar' }, 'radar'),
);
});
test('a metered tile waits out Retry-After instead of leaving a hole', async () => {
const seen = new Map();
const waits = [];
const result = await composeMercatorImage({
...options,
bbox: { west: -180, south: -85.0511, east: 180, north: 85.0511 },
size: { width: 512, height: 256 },
maxTiles: 1,
fetchImpl: async (url) => {
const count = (seen.get(url) ?? 0) + 1;
seen.set(url, count);
return count === 1
? new Response('', { status: 429, headers: { 'Retry-After': '3' } })
: new Response(new Uint8Array([1]));
},
sleep: async (ms) => {
waits.push(ms);
},
});
assert.deepEqual(waits, [3000]);
assert.equal(result.texture.draws[0][0].draws.length, 1);
});