skip Nominatim lookups when the server has none configured

Add GET /api/geocode/status ({configured}), like /api/overpass/status. The outline client and the last-resort place search ask it once per page and send no lookup a server without a Nominatim endpoint would refuse, so a default install no longer logs a 503 on its first named landmark or grounds ask. An older server or a failed probe leaves the capability unknown and lookups proceed as before.
This commit is contained in:
manjunath committed 2026-10-30 22:33:55 +05:30
1 parent 24228b145e
commit f32ab5446b
10 files changed
+157 -6

No files matched your search

+3
View File
@@ -27,3 +27,6 @@
First sources whose licence is only listed as CC BY or CC0 "equivalent", First sources whose licence is only listed as CC BY or CC0 "equivalent",
including the Ottawa, Saskatoon, Gatineau and Madrid records including the Ottawa, Saskatoon, Gatineau and Madrid records
(Manjunath, #842). (Manjunath, #842).
- Skip Nominatim place and outline lookups when the server reports that no
Nominatim endpoint is configured, so a default install sends no request it
would refuse (Manjunath, #842).
+5
View File
@@ -5298,6 +5298,11 @@ Google first when configured, Photon/OpenStreetMap as fallback (including Google
transport failures or declined requests), and the local `/api/geocode` route transport failures or declined requests), and the local `/api/geocode` route
over Nominatim as a last resort when neither answers and an operator explicitly over Nominatim as a last resort when neither answers and an operator explicitly
configures `NOMINATIM_URL`. Unset, empty and invalid values disable Nominatim. configures `NOMINATIM_URL`. Unset, empty and invalid values disable Nominatim.
`GET /api/geocode/status` answers `{configured}`; the browser asks once per
page, and when Nominatim is not configured neither the last-resort search nor
the voice outline client asks `/api/geocode`, so a default install sends no
lookup it would refuse. A missing or failed probe leaves the capability unknown
and lookups proceed as before.
Explicit public-service configuration retains an identifying `User-Agent` and `Referer`, at Explicit public-service configuration retains an identifying `User-Agent` and `Referer`, at
least 1.1 seconds between public search and outline starts, a shared durable least 1.1 seconds between public search and outline starts, a shared durable
per-install daily allowance and pause state that fail closed when unusable. per-install daily allowance and pause state that fail closed when unusable.
+4
View File
@@ -396,6 +396,7 @@
"src/search/geospatial.js", "src/search/geospatial.js",
"src/search/http.js", "src/search/http.js",
"src/search/defaults.js", "src/search/defaults.js",
"src/sources/nominatimStatus.js",
"src/search/alpsGeocoder.js", "src/search/alpsGeocoder.js",
"src/search/coordinateGeocoder.js", "src/search/coordinateGeocoder.js",
"src/search/coordinateParser.js", "src/search/coordinateParser.js",
@@ -2822,6 +2823,7 @@
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js", "src/data/placeBoundaries.js",
"src/data/placeNames.js", "src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js", "src/data/local_data/us_census_places/files.js",
@@ -3413,6 +3415,7 @@
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js", "src/data/placeBoundaries.js",
"src/data/placeNames.js", "src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js", "src/data/local_data/us_census_places/files.js",
@@ -3496,6 +3499,7 @@
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js", "src/data/placeBoundaries.js",
"src/data/placeNames.js", "src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js", "src/data/local_data/us_census_places/files.js",
+6
View File
@@ -474,6 +474,12 @@ export function geocodeProxy({
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/geocode', async (req, res, next) => { middlewares.use('/api/geocode', async (req, res, next) => {
const url = new URL(req.url || '', 'http://localhost'); const url = new URL(req.url || '', 'http://localhost');
// Capability probe, like /api/overpass/status: clients skip lookups a
// server without a Nominatim endpoint could only refuse.
if (url.pathname === '/status' && req.method === 'GET')
return sendJson(res, 200, {
configured: Boolean(currentGate().settings()?.endpoint),
});
if (url.pathname === '/outline') return handleOutline(req, res, url); if (url.pathname === '/outline') return handleOutline(req, res, url);
if (url.pathname === '/' || url.pathname === '') if (url.pathname === '/' || url.pathname === '')
return handleSearch(req, res, url); return handleSearch(req, res, url);
+11 -1
View File
@@ -2,6 +2,7 @@ import { createNominatimProvider } from './nominatim.js';
import { createGeospatialServices } from './geospatial.js'; import { createGeospatialServices } from './geospatial.js';
import { createHttpGeospatialProvider } from './http.js'; import { createHttpGeospatialProvider } from './http.js';
import { createPlaceSearch } from './placeSearch.js'; import { createPlaceSearch } from './placeSearch.js';
import { createNominatimStatus } from '../sources/nominatimStatus.js';
import { createGoogleGeocoder } from './google.js'; import { createGoogleGeocoder } from './google.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js'; import { createPhotonGeocoder } from '../keylessGeocoder.js';
import { createCoordinateGeocoder } from './coordinateGeocoder.js'; import { createCoordinateGeocoder } from './coordinateGeocoder.js';
@@ -28,6 +29,9 @@ export function createDefaultPlaceSearch({
} = {}) { } = {}) {
if (geocoding && geocoding.provider !== 'nominatim') if (geocoding && geocoding.provider !== 'nominatim')
throw new TypeError('Unsupported geocoding provider'); throw new TypeError('Unsupported geocoding provider');
const nominatimConfigured = endpoints.nominatim
? null
: createNominatimStatus({ fetchImpl });
const selected = geocoding const selected = geocoding
? createNominatimProvider({ ...geocoding, fetchImpl }) ? createNominatimProvider({ ...geocoding, fetchImpl })
: null; : null;
@@ -74,7 +78,13 @@ export function createDefaultPlaceSearch({
// neither of the two above did. It speaks the same result shape, so it // neither of the two above did. It speaks the same result shape, so it
// rides the existing Google adapter rather than needing its own. // rides the existing Google adapter rather than needing its own.
createGoogleGeocoder({ createGoogleGeocoder({
request(query, { bias, signal }) { async request(query, { bias, signal }) {
// The app's own route says up front when Nominatim is off.
if (
nominatimConfigured &&
(await nominatimConfigured()) === false
)
return null;
const params = new URLSearchParams({ q: query }); const params = new URLSearchParams({ q: query });
if (bias) params.set('bounds', bias); if (bias) params.set('bounds', bias);
return fetchImpl( return fetchImpl(
+23
View File
@@ -30,3 +30,26 @@ test('place search follows the Photon setting', async () => {
const off = await hosts({ photon: null }); const off = await hosts({ photon: null });
assert.ok(!off.some((host) => host.includes('photon')), off.join(',')); assert.ok(!off.some((host) => host.includes('photon')), off.join(','));
}); });
test('the last-resort route is skipped when the server reports Nominatim unconfigured', async () => {
const run = async (configured) => {
const asked = [];
const search = createDefaultPlaceSearch({
endpoints: { photon: null },
fetchImpl: async (url) => {
const path = new URL(String(url), 'http://local.test').pathname;
asked.push(path);
if (path === '/api/geocode/status')
return Response.json({ configured });
return Response.json({ status: 'ZERO_RESULTS', results: [] });
},
});
await search.geocode(`nowhere-nominatim-${configured}`);
return asked;
};
const off = await run(false);
assert.ok(off.includes('/api/geocode/status'));
assert.ok(!off.includes('/api/geocode'), off.join(','));
const on = await run(true);
assert.ok(on.includes('/api/geocode'), on.join(','));
});
+15
View File
@@ -1,4 +1,5 @@
import { readResponseJsonCapped } from './httpBody.js'; import { readResponseJsonCapped } from './httpBody.js';
import { createNominatimStatus } from './nominatimStatus.js';
/** /**
* Browser client for the server's guarded outline route. The server owns the * Browser client for the server's guarded outline route. The server owns the
@@ -61,6 +62,11 @@ export function createNominatimOutlineClient({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = DEFAULT_ENDPOINT, endpoint = DEFAULT_ENDPOINT,
maxRemembered = 64, maxRemembered = 64,
// The app's own route can say up front that Nominatim is not configured;
// another endpoint is simply asked.
configured = endpoint === DEFAULT_ENDPOINT
? createNominatimStatus({ fetchImpl })
: null,
} = {}) { } = {}) {
let unavailable = null; let unavailable = null;
const remembered = new Map(); const remembered = new Map();
@@ -90,6 +96,15 @@ export function createNominatimOutlineClient({
if (remembered.has(key)) return remembered.get(key); if (remembered.has(key)) return remembered.get(key);
// A cancelled ask spends nothing. // A cancelled ask spends nothing.
signal?.throwIfAborted(); signal?.throwIfAborted();
if (configured && (await configured()) === false) {
signal?.throwIfAborted();
unavailable = {
unavailable: true,
retryable: false,
code: 'NOMINATIM_DISABLED',
};
return unavailable;
}
const controller = new AbortController(); const controller = new AbortController();
const abort = () => controller.abort(); const abort = () => controller.abort();
+45 -5
View File
@@ -11,20 +11,30 @@ const ring = [
[2.3, 48.8], [2.3, 48.8],
]; ];
function client(respond) { const reply = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers });
// `calls` counts outline requests only; the capability probe is answered by
// `status` and defaults to an older server without it (capability unknown).
function client(
respond,
{ status = () => reply(404, { error: 'Not Found' }) } = {},
) {
const calls = []; const calls = [];
const probes = [];
const lookup = createNominatimOutlineClient({ const lookup = createNominatimOutlineClient({
fetchImpl: async (url) => { fetchImpl: async (url) => {
if (String(url) === '/api/geocode/status') {
probes.push(String(url));
return status(probes.length);
}
calls.push(String(url)); calls.push(String(url));
return respond(calls.length); return respond(calls.length);
}, },
}).lookup; }).lookup;
return { calls, lookup }; return { calls, probes, lookup };
} }
const reply = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers });
test('an accepted outline comes back with its polygons and is remembered', async () => { test('an accepted outline comes back with its polygons and is remembered', async () => {
const hole = ring.map(([lon, lat]) => [ const hole = ring.map(([lon, lat]) => [
2.35 + (lon - 2.35) / 2, 2.35 + (lon - 2.35) / 2,
@@ -139,3 +149,33 @@ test('a cancelled ask sends nothing', async () => {
); );
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
}); });
test('a server that reports Nominatim unconfigured is never asked for outlines', async () => {
const { calls, probes, lookup } = client(
() => {
throw new Error('the outline route must not be asked');
},
{ status: () => reply(200, { configured: false }) },
);
const first = await lookup({ query: 'Austin High School', kind: 'landmark' });
assert.deepEqual(first, {
unavailable: true,
retryable: false,
code: 'NOMINATIM_DISABLED',
});
const second = await lookup({ query: 'Paris', kind: 'city' });
assert.equal(second.unavailable, true);
assert.equal(calls.length, 0, 'no outline request at all');
assert.equal(probes.length, 1, 'one capability probe per session');
});
test('a configured server is asked as usual after one probe', async () => {
const { calls, probes, lookup } = client(
() => reply(200, { status: 'ZERO_RESULTS', outline: null }),
{ status: () => reply(200, { configured: true }) },
);
assert.equal(await lookup({ query: 'Nowhere', kind: 'landmark' }), null);
assert.equal(await lookup({ query: 'Elsewhere', kind: 'landmark' }), null);
assert.equal(calls.length, 2);
assert.equal(probes.length, 1);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Whether the app's server has a Nominatim endpoint configured, asked once per
* page from `/api/geocode/status` (like `/api/overpass/status`). When it says
* no, callers skip lookups the server could only refuse, so a default install
* never asks and never logs a failed request. An older server without the
* probe, a failed or timed-out probe and a malformed answer leave the
* capability unknown (`null`): lookups then go to the server as before, and
* the probe is retried after a short backoff.
*
* @returns {() => Promise<boolean|null>}
*/
export function createNominatimStatus({
fetchImpl = (...args) => globalThis.fetch(...args),
url = '/api/geocode/status',
timeoutMs = 3000,
retryMs = 30_000,
now = Date.now,
} = {}) {
let decision = null;
let retryAt = 0;
return function configured() {
if (decision) return decision;
if (now() < retryAt) return Promise.resolve(null);
decision = (async () => {
try {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(timeoutMs),
});
const body = response.ok ? await response.json() : null;
if (typeof body?.configured === 'boolean') return body.configured;
} catch {
// Unknown capability: ask the server as before.
}
decision = null;
retryAt = now() + retryMs;
return null;
})();
return decision;
};
}
@@ -1235,10 +1235,15 @@ test('the outline route reports disabled and capped as permanent, busy as retrya
assert.equal(off.status, 503); assert.equal(off.status, 503);
assert.equal(off.body.code, 'NOMINATIM_DISABLED'); assert.equal(off.body.code, 'NOMINATIM_DISABLED');
assert.equal(off.body.retryable, false); assert.equal(off.body.retryable, false);
// The capability probe answers 200, so a client can skip the refused lookup.
const probe = await disabled('/status');
assert.equal(probe.status, 200);
assert.deepEqual(probe.body, { configured: false });
const capped = mount({ const capped = mount({
gate: publicGate(t, { settings: { ...PUBLIC, dailyCap: 0 } }), gate: publicGate(t, { settings: { ...PUBLIC, dailyCap: 0 } }),
}); });
assert.deepEqual((await capped('/status')).body, { configured: true });
const cap = await capped('/outline?q=Paris&kind=city'); const cap = await capped('/outline?q=Paris&kind=city');
assert.equal(cap.status, 429); assert.equal(cap.status, 429);
assert.equal(cap.body.retryable, false); assert.equal(cap.body.retryable, false);