diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..24ed601cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Boundary capability checks recover after unsuccessful or malformed status responses, using the existing retry backoff. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..d8409281f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4066,7 +4066,8 @@ when its own signal aborts. Configured endpoints have per-instance cooldowns for 406/429, honoring Retry-After; absent delays back off from 30 seconds to five minutes. Empty `elements` is a valid answer. Existing byte, query, concurrency and cache caps -remain. Consumer capability misses never schedule automatic retries. +remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. +Consumer capability misses never schedule automatic retries. Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..3c4b9165b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; diff --git a/src/services/boundary-probe-recovery.test.mjs b/src/services/boundary-probe-recovery.test.mjs new file mode 100644 index 000000000..cc0c47c08 --- /dev/null +++ b/src/services/boundary-probe-recovery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; +test('unknown capability replies recover after backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100_000 }); + for (const first of [ + () => Response.json({ wrong: true }), + () => new Response('bad', { status: 503 }), + ]) { + let probes = 0; + let queries = 0; + const services = createApplicationRequestServices({ + boundaryProbe: { timeoutMs: 1000, retryMs: 1000 }, + fetchImpl: async (url) => { + if (url.endsWith('/status')) + return ++probes === 1 + ? first() + : Response.json({ configured: false }); + queries++; + return Response.json({ elements: [] }); + }, + }); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff'); + t.mock.timers.tick(1000); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal(probes, 2); + assert.equal(queries, 2); + } +}); diff --git a/src/services/requests.js b/src/services/requests.js index bb0b4cefd..3e3a819eb 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -66,9 +66,9 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - // Learn once per page whether the server has an Overpass instance, so an + // Remember valid capability decisions for the page, so an // unconfigured server is never asked (and never answers with an error). - // Discovery is bounded; a failed or timed-out probe is forgotten and not + // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. let boundaryCapability = null; @@ -79,10 +79,13 @@ export function createApplicationRequestServices({ boundaryCapability = request(`${urls.boundaries}/status`, { signal: AbortSignal.timeout(boundaryProbe.timeoutMs), }).then( - (response) => - response.ok && typeof response.data?.configured === 'boolean' - ? response.data.configured - : null, // an older server: fall back to asking per query + (response) => { + if (response.ok && typeof response.data?.configured === 'boolean') + return response.data.configured; + boundaryCapability = null; + boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs; + return null; // capability unknown: let this query ask the server + }, (error) => { boundaryCapability = null; if (lifetime?.aborted) throw error;