mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
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:
1 parent
24228b145e
commit
f32ab5446b
10 files changed
+157
-6
No files matched your search
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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(','));
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user