Merge branch 'main' into pr/pause-hidden-polling

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-28 16:35:38 +00:00
commit 437f95c1ee
9 files changed
+579 -11

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
- A "place, region" search such as "Paris, Texas" now lands on the place in
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
+5
View File
@@ -0,0 +1,5 @@
- The terrain proxy stops asking the upstream for a while after a 429
(its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every
page request, and a Retry-After longer than its 10 s retry budget now ends
the retries at once instead of sleeping the budget out (quanticsoul4772,
#1402).
+15 -2
View File
@@ -3092,7 +3092,14 @@ 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. A `Retry-After`
> longer than the 10 s retry budget ends the retries at once. After a 429 that
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. 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
@@ -5281,7 +5288,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
nearby place whose whole name is the query (one with a comma in it) comes next,
then the unbiased name match. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe. are omitted rather than framing the wrong part of the globe.
A location search that finds nothing, or fails, leaves the field open with the A location search that finds nothing, or fails, leaves the field open with the
+47 -6
View File
@@ -6,6 +6,7 @@ import {
parseTerrainPoints, parseTerrainPoints,
resolveTerrainHeightRequest, resolveTerrainHeightRequest,
terrainPointKey, terrainPointKey,
terrainRetryAfterMs,
validTerrainResult, validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js'; } from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js';
@@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) {
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
// that range. // that range.
const UPSTREAM_CHUNK = 64; const UPSTREAM_CHUNK = 64;
// After a 429 the upstream is left alone for its Retry-After (at most
// RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a
// restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for
// new points (about once a second while the camera moves) otherwise asks
// again with its own retries, which keeps the limit in force and logs each
// time.
const RATE_LIMIT_PAUSE_MS = 60_000;
const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000;
let rateLimitedUntil = 0;
const MAX_POINTS = UPSTREAM_CHUNK; const MAX_POINTS = UPSTREAM_CHUNK;
/** /**
* Entry ceiling for the point cache, and with it the file written from it. * Entry ceiling for the point cache, and with it the file written from it.
@@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) {
* *
* The first try keeps its empirically required 30s timeout; network errors, * The first try keeps its empirically required 30s timeout; network errors,
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time * 429, and 5xx receive up to three jittered retries sharing a 10s added-time
* budget, with Retry-After honored within that bound. * budget, with Retry-After honored within that bound; a longer Retry-After
* ends the retries at once so the 429 pause below starts without delay.
* *
* A chunk that still fails yields nulls for its own positions rather than * A chunk that still fails yields nulls for its own positions rather than
* rejecting: with several chunks per request, throwing would discard every * rejecting: with several chunks per request, throwing would discard every
@@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) {
* @returns {Promise<Array<object>>} * @returns {Promise<Array<object>>}
*/ */
async function fetchUpstreamAll(points) { async function fetchUpstreamAll(points) {
if (Date.now() < rateLimitedUntil) {
// Rate-limited: asking again only extends the limit. Cached points are
// still served; the rest resolve on the page through its geoid.
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
}
const results = []; const results = [];
let firstError = null; let firstError = null;
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 = [];
try { // Re-checked per chunk for when a request spans several chunks again;
chunkResults = await fetchTerrainChunkWithRetry(chunk); // today requests are capped at one chunk (MAX_POINTS).
} catch (error) { if (Date.now() >= rateLimitedUntil) {
firstError = firstError || error; try {
chunkResults = await fetchTerrainChunkWithRetry(chunk);
} catch (error) {
firstError = firstError || error;
// Concurrent 429s extend the pause, never cut it short.
if (error?.status === 429)
rateLimitedUntil = Math.max(
rateLimitedUntil,
Date.now() +
Math.min(
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_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
// trailing positions. The resolver will reject each null individually. // trailing positions. The resolver will reject each null individually.
@@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) {
evictOverflow(); evictOverflow();
diskDirty = true; diskDirty = true;
} }
if (outcome.upstreamError) { if (outcome.upstreamError?.coolingDown) {
// Logged once, when the pause began.
} else if (outcome.upstreamError) {
const pause =
outcome.upstreamError.status === 429 &&
Date.now() < rateLimitedUntil
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
: '';
console.warn( console.warn(
'[terrain-heights-proxy] refresh incomplete' + '[terrain-heights-proxy] refresh incomplete' +
pause +
' — serving stale points when available', ' — serving stale points when available',
); );
} else if (outcome.absentPoints > 0) { } else if (outcome.absentPoints > 0) {
+6 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/** /**
* Fetch one upstream chunk with jittered exponential backoff. Network errors, * Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast. * 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one. * Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
* *
* Dependencies are injectable for deterministic offline tests. * Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points * @param {Array<[number, number]>} points
@@ -145,6 +148,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;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break; if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now()); const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round( const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5), 350 * 2 ** attempt * (0.75 + random() * 0.5),
); );
+30
View File
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
assert.equal(results[0].id, 'recovered'); assert.equal(results[0].id, 'recovered');
}); });
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
let clock = 3_000_000;
let attempts = 0;
const sleeps = [];
await assert.rejects(
fetchTerrainChunkWithRetry([[1, 1]], {
now: () => clock,
random: () => 0.5,
makeSignal: () => undefined,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
fetchImpl: async () => {
attempts += 1;
return {
ok: false,
status: 429,
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
},
};
},
}),
(error) => error.status === 429 && error.retryAfter === '30',
);
assert.equal(attempts, 1);
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
});
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => { test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
let clock = 2_000_000; let clock = 2_000_000;
let attempts = 0; let attempts = 0;
+81 -1
View File
@@ -281,6 +281,67 @@ export function selectPhotonFeature(
return null; return null;
} }
/** Short country names people type that Photon's fields don't carry. */
const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' });
/**
* OSM keys of settlements and administrative areas. Among candidates that fit
* a "place, region" query, these are what the region names a place in: a cafe
* called "Paris" in Lyon is in France too, but "Paris, France" means the city.
*/
const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary']));
/** The region part of a "place, region" query, normalized ('' if none). */
function queryRegion(query) {
const [, ...rest] = String(query ?? '').split(',');
return normalizeToponym(rest.join(' '));
}
/**
* For a "place, region" query, pick the candidate named for the place that
* actually lies in the region. "Paris, Texas" is also the name of a pub in
* Budapest: matched as one name it beat the town, so the region has to be
* checked against where each candidate is, not against its name. When several
* candidates fit, a settlement or administrative area wins over a POI.
* @param {object[]} features - Photon features, in the order returned.
* @param {string} query - The query as typed.
* @returns {?object} null when the query names no region or nothing fits.
*/
export function selectQualifiedFeature(features, query) {
const [headText] = String(query ?? '').split(',');
const region = queryRegion(query);
const head = normalizeToponym(headText);
if (!head || !region) return null;
const words = region.split(' ');
const qualified = (Array.isArray(features) ? features : []).filter(
(feature) => {
const properties = feature?.properties ?? {};
const name = normalizeToponym(properties.name);
// Exactly the place: "Paris Gare de Lyon" is in France too, but it
// is not what "Paris, France" asks for.
if (name !== head) return false;
const where = ` ${normalizeToponym(
[
properties.city,
properties.county,
properties.state,
properties.country,
properties.countrycode,
COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()],
].join(' '),
)} `;
return words.every((word) => where.includes(` ${word} `));
},
);
return (
qualified.find((feature) =>
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
) ??
qualified[0] ??
null
);
}
/** Evict the oldest memo entries until the cache is within its cap. */ /** Evict the oldest memo entries until the cache is within its cap. */
function trimPhotonCache(cache) { function trimPhotonCache(cache) {
while (cache.size > PHOTON_CACHE_MAX) { while (cache.size > PHOTON_CACHE_MAX) {
@@ -359,12 +420,28 @@ export async function geocodeKeylessWithOutcome(
// to the wrong continent. So a biased answer is only accepted when it leads // to the wrong continent. So a biased answer is only accepted when it leads
// with the name asked for; otherwise the same query runs unbiased, which is // with the name asked for; otherwise the same query runs unbiased, which is
// what a user searching for somewhere off-screen meant in the first place. // what a user searching for somewhere off-screen meant in the first place.
// A query naming a region is answered nearby only by a match inside it.
// A nearby place whose own name has a comma ("Bed, Bath & Beyond") and
// equals the whole query is kept as the last choice, after the region
// checks of both passes.
let feature = null; let feature = null;
let nearbyNameMatch = null;
let everyAskAnswered = true; let everyAskAnswered = true;
if (bias) { if (bias) {
const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint }));
if (biased === null) everyAskAnswered = false; if (biased === null) everyAskAnswered = false;
else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); else if (queryRegion(trimmed)) {
feature = selectQualifiedFeature(biased, trimmed);
// Exactly the whole query: "Springfield IL Auto" only starts with it.
const whole = normalizeToponym(trimmed);
nearbyNameMatch =
biased.find(
(candidate) =>
normalizeToponym(candidate?.properties?.name) === whole,
) ?? null;
} else {
feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
}
} }
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -379,9 +456,12 @@ export async function geocodeKeylessWithOutcome(
// the thing being searched for; the tail only says where to look. // the thing being searched for; the tail only says where to look.
const head = normalizeToponym(trimmed.split(',')[0]); const head = normalizeToponym(trimmed.split(',')[0]);
feature = feature =
selectQualifiedFeature(anywhere, trimmed) ||
nearbyNameMatch ||
selectPhotonFeature(anywhere, head, { allowContains: true }) || selectPhotonFeature(anywhere, head, { allowContains: true }) ||
anywhere[0]; anywhere[0];
} }
if (!feature) feature = nearbyNameMatch;
} }
signal?.throwIfAborted(); signal?.throwIfAborted();
+240 -1
View File
@@ -30,6 +30,7 @@ import {
photonResultTypes, photonResultTypes,
photonSearchUrl, photonSearchUrl,
selectPhotonFeature, selectPhotonFeature,
selectQualifiedFeature,
} from './keylessGeocoder.js'; } from './keylessGeocoder.js';
import { geocodeNavigationMode } from './locations.js'; import { geocodeNavigationMode } from './locations.js';
@@ -288,6 +289,245 @@ test('a "place, region" query is matched on the place, not the region', async ()
assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`);
}); });
/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */
const PARIS_TX = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' },
};
const PARIS_TEXAS_PUB = {
geometry: { type: 'Point', coordinates: [19.0606, 47.4868] },
properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' },
};
const PARIS_TEXAS_SALON = {
geometry: { type: 'Point', coordinates: [153.03, -27.47] },
properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' },
};
test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => {
// Measured from Denver against the live service: the biased answer listed the
// town first, but the pub's name equals the whole query and won, flying the
// camera to Budapest at rooftop range.
const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is checked against where a candidate is, not its name', () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX);
// No region, or a region nothing lies in, leaves the choice to the name rules.
assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null);
assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null);
});
test('a region nearby fits nothing, so the unbiased answer decides', async () => {
const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]);
const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 2);
});
test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX);
// "Paris," names no region: the camera-biased pass still answers.
const fetchImpl = photonPair([PARIS_TX], []);
const result = await geocodeKeyless('Paris,', {
bias: '33.60,-95.60|33.70,-95.50',
fetchImpl,
});
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region match takes the place itself, not a place whose name starts with it', () => {
const station = {
geometry: { type: 'Point', coordinates: [4.86, 45.76] },
properties: {
osm_key: 'railway',
name: 'Paris Gare de Lyon',
city: 'Paris',
country: 'France',
countrycode: 'FR',
},
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: {
osm_key: 'place',
name: 'Paris',
state: 'Île-de-France',
country: 'France',
countrycode: 'FR',
},
};
assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris);
assert.equal(selectQualifiedFeature([station], 'Paris, France'), null);
});
test('"UK" names the United Kingdom', () => {
const london = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: {
osm_key: 'place',
name: 'London',
country: 'United Kingdom',
countrycode: 'GB',
},
};
const ontario = {
geometry: { type: 'Point', coordinates: [-81.25, 42.98] },
properties: {
osm_key: 'place',
name: 'London',
state: 'Ontario',
country: 'Canada',
countrycode: 'CA',
},
};
assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london);
});
test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => {
const store = (lon, lat, city) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
osm_key: 'shop',
osm_value: 'houseware',
name: 'Bed, Bath & Beyond',
city,
countrycode: 'US',
},
});
const nearby = store(-104.94, 39.66, 'Denver');
const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]);
const result = await geocodeKeyless('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
});
assert.match(result.label, /Denver/);
// Main answered this from the nearby pass alone. The second request is the
// accepted cost of checking the region unbiased first.
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
});
test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => {
const place = (name, lon, lat, props) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { osm_key: 'place', name, ...props },
});
const nearby = [
place('Springfield IL Auto', -89.6, 39.8, {
osm_key: 'shop',
city: 'Springfield',
state: 'Illinois',
countrycode: 'US',
}),
];
const anywhere = [
place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }),
];
const fetchImpl = photonPair(nearby, anywhere);
const result = await geocodeKeyless('Springfield, IL', {
bias: '39.70,-89.70|39.90,-89.50',
fetchImpl,
});
assert.doesNotMatch(result.label, /Auto/);
assert.match(result.label, /^Springfield/);
});
test('a region query without a camera bias still takes the place in the region', async () => {
// Unbiased, Photon can list the pub first; main took it because its name
// starts with "Paris".
const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is matched by whole words, and every word has to fit', () => {
const londonUk = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' },
};
// "on" is inside "london" and "england", but it is not a word of either.
assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null);
// "Texas" fits, "Ontario" does not, so the town does not qualify.
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null);
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null);
});
test('a region can be the city or the county a candidate lies in', () => {
const street = {
geometry: { type: 'Point', coordinates: [-105.27, 40.02] },
properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' },
};
const town = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' },
};
assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street);
assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town);
});
test('when a POI and the place both fit the region, the place wins', async () => {
// A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the
// city, and it would frame at rooftop range.
const cafe = {
geometry: { type: 'Point', coordinates: [4.84, 45.76] },
properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' },
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' },
};
const fetchImpl = photonPair([cafe, paris], [paris]);
const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl });
assert.equal(Math.round(result.lat), 49, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
// A POI alone still qualifies: "Main Street, Denver" names a street.
assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe);
});
test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => {
const store = {
geometry: { type: 'Point', coordinates: [-104.94, 39.66] },
properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' },
};
const urls = [];
const fetchImpl = (url) => {
urls.push(url);
if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) });
};
const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
cache: new Map(),
});
assert.match(outcome.place.label, /Denver/);
assert.equal(outcome.answered, true);
assert.equal(urls.length, 2);
});
test('accents and punctuation do not change what a name compares as', () => { test('accents and punctuation do not change what a name compares as', () => {
assert.equal(normalizeToponym('Huế'), 'hue'); assert.equal(normalizeToponym('Huế'), 'hue');
assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay');
@@ -453,7 +693,6 @@ function readGeocodingConsumer(file) {
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
} }
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
const params = new URL(photonSearchUrl('island', { bias })).searchParams; const params = new URL(photonSearchUrl('island', { bias })).searchParams;
+153
View File
@@ -411,3 +411,156 @@ test('terrain middleware retains successful chunks around a failure and retries
); );
assert.equal(requests.length, 4); assert.equal(requests.length, 4);
}); });
test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => {
isolate(t);
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 429 });
});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const request = install(terrainHeightsProxy());
// 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);
}
assert.ok(settled, 'the first request settles within its retry budget');
await first;
const afterFirst = calls;
assert.ok(afterFirst >= 1);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/);
// New points during the pause: no upstream call, no new log line.
const paused = await request('/?points=3,4');
assert.equal(calls, afterFirst);
assert.equal(paused.status, 502);
assert.equal(warnings.length, 1);
});
test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => {
isolate(t);
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let limited = true;
t.mock.method(globalThis, 'fetch', async () =>
limited
? new Response('', { status: 429, headers: { 'retry-after': '86400' } })
: Response.json({ results: [{ ellipsoid: 100 }] }),
);
const request = install(terrainHeightsProxy());
// The first request spends its retry budget on 429s, then the pause begins.
let settled = false;
const first = request('/?points=1,2').then((res) => {
settled = true;
return res;
});
// Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty.
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the first request settles within its retry budget');
assert.equal((await first).status, 502);
limited = false;
const pausedAt = Date.now();
t.mock.timers.setTime(pausedAt + 9 * 60_000);
assert.equal((await request('/?points=3,4')).status, 502, 'still paused');
t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1);
assert.equal((await request('/?points=3,4')).status, 200, 'pause over');
});
/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */
async function settleOnMockedTime(t, promise) {
let settled = false;
const tracked = promise.finally(() => {
settled = true;
});
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the request settles within its retry budget');
return tracked;
}
test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
let limited = false;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return limited
? new Response('', { status: 429, headers: { 'retry-after': '300' } })
: Response.json({ results: [{ ellipsoid: 100 }] });
});
const request = install(terrainHeightsProxy());
assert.equal((await request('/?points=5,6')).status, 200);
// Past the 30-day TTL, so 5,6 is stale and would normally be refreshed.
t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000);
limited = true;
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
assert.equal(calls, 2, 'a Retry-After past the budget is not retried');
const stale = await request('/?points=5,6');
assert.equal(stale.status, 200);
assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]);
assert.equal((await request('/?points=5,6;7,8')).status, 502);
assert.equal(calls, 2, 'no upstream call during the pause');
});
test('a terrain upstream failure other than 429 does not pause later requests', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 503 });
});
const request = install(terrainHeightsProxy());
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
const afterFirst = calls;
const second = await settleOnMockedTime(t, request('/?points=3,4'));
assert.equal(second.status, 502);
assert.ok(calls > afterFirst, 'new points still go upstream after a 503');
});
test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
calls += 1;
const points = new URL(raw).searchParams.get('points');
if (points.startsWith('0.'))
return Response.json({ results: [{ ellipsoid: 1 }] });
const retryAfter = points.startsWith('1.') ? '300' : '30';
return new Response('', {
status: 429,
headers: { 'retry-after': retryAfter },
});
});
const request = install(terrainHeightsProxy());
// Load the disk cache first so both requests below reach the upstream in
// order: the 300 s answer lands first, the 30 s answer after it.
assert.equal((await request('/?points=0,0')).status, 200);
const [long, short] = await settleOnMockedTime(
t,
Promise.all([request('/?points=1,2'), request('/?points=3,4')]),
);
assert.equal(long.status, 502);
assert.equal(short.status, 502);
const afterBoth = calls;
t.mock.timers.setTime(Date.now() + 120_000);
assert.equal((await request('/?points=5,6')).status, 502);
assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After');
});