diff --git a/src/layers/weather/mercatorMosaic.js b/src/layers/weather/mercatorMosaic.js index 2349b9931..b42a60887 100644 --- a/src/layers/weather/mercatorMosaic.js +++ b/src/layers/weather/mercatorMosaic.js @@ -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(); diff --git a/src/layers/weather/mercatorMosaic.test.mjs b/src/layers/weather/mercatorMosaic.test.mjs index a81665239..d58c2af83 100644 --- a/src/layers/weather/mercatorMosaic.test.mjs +++ b/src/layers/weather/mercatorMosaic.test.mjs @@ -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); +});