fix(news): keep a refused key refused during a slow read, and read Local when Conflicts fails

A slow re-read of a refused key read as unavailable until it landed; it
now stays refused. The first-read wait is 10 s, for a margin under the
browser's 15 s. A failed Conflicts read no longer stops Local's refresh,
which the server reads on its own. Tests cover GDELT's wait, the refused
key and a caller abort past the timeout, and fail rather than hang; the
docs say what a section serves meanwhile.
This commit is contained in:
Denis Groshev committed 2026-11-06 12:02:58 +01:00
1 parent c6ae4c44d7
commit 61424e4256
7 files changed
+176 -42

No files matched your search

+1 -1
View File
@@ -5853,7 +5853,7 @@ request viewbox and continues searching without that geographic preference.
- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report.
- **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working.
- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set.
- **Built-in backend.** With `GEV_NEWS_ORIGIN` unset, Core answers the same routes itself (`server/providers/news/keyed.js`). **Conflicts** needs no key: the last 24 h of GDELT 2.0 event exports (15-minute files on `data.gdeltproject.org`, about 100 KB each), physical violence only (CAMEO roots 18–20), one report per story with copies of one agency story merged, placed at GDELT's location for it (a town the article's words name beats a bare country). Titles are the article's page title from GDELT's GKG export of the same quarter hour (about 6 MB each) for the newest 3 h, else the words of the article's address; GDELT's actor coding is never shown. With `LIVEUAMAP_API_KEY`, Liveuamap's mapped items stand beside GDELT's. **Local** reads World News API (`WORLDNEWSAPI_KEY`) by the source country at the view centre, once per country every 30 minutes (about 1.5 points a read, at most `WORLDNEWSAPI_DAILY_READS` reads a day, default 30), plus the Conflicts reports placed inside the area. A country's own stories keep World News API's category (politics, business, else other) and no severity: the rules read fighting and crises, and everyday headlines mislead them. Areas come from the bundled Natural Earth countries and disputed areas (`server/providers/news/areas.js`). Conflicts' categories, relevance and severity are bundled rules (`categorize.js`); a news server may replace them. A read with nothing to serve yet (the first of GDELT's day, of Liveuamap or of a country) is waited for at most 12 s, under the client's 15 s timeout: past that the section answers unavailable (502) while the read goes on, and the next refresh is served from it.
- **Built-in backend.** With `GEV_NEWS_ORIGIN` unset, Core answers the same routes itself (`server/providers/news/keyed.js`). **Conflicts** needs no key: the last 24 h of GDELT 2.0 event exports (15-minute files on `data.gdeltproject.org`, about 100 KB each), physical violence only (CAMEO roots 18–20), one report per story with copies of one agency story merged, placed at GDELT's location for it (a town the article's words name beats a bare country). Titles are the article's page title from GDELT's GKG export of the same quarter hour (about 6 MB each) for the newest 3 h, else the words of the article's address; GDELT's actor coding is never shown. With `LIVEUAMAP_API_KEY`, Liveuamap's mapped items stand beside GDELT's. **Local** reads World News API (`WORLDNEWSAPI_KEY`) by the source country at the view centre, once per country every 30 minutes (about 1.5 points a read, at most `WORLDNEWSAPI_DAILY_READS` reads a day, default 30), plus the Conflicts reports placed inside the area. A country's own stories keep World News API's category (politics, business, else other) and no severity: the rules read fighting and crises, and everyday headlines mislead them. Areas come from the bundled Natural Earth countries and disputed areas (`server/providers/news/areas.js`). Conflicts' categories, relevance and severity are bundled rules (`categorize.js`); a news server may replace them. A read with nothing current to serve (GDELT's, Liveuamap's or a country's) is waited for at most 10 s, under the client's 15 s timeout, and goes on past that for the next refresh. Meanwhile GDELT serves the exports read so far and Conflicts serves GDELT without Liveuamap; Local in a country answers unavailable (502) until its outlets' read lands, and serves them without the Conflicts reports while those are missing. A section left with nothing answers unavailable, and a refused key stays refused.
- **State.** Share/restore token `9`, enabled-only. Voice is off for this layer (`VOICE_OFF_LAYERS`).
### S2 GhostMaps (October 2026)
+5 -3
View File
@@ -145,9 +145,11 @@ Status: Open (limits)
- A country's own stories in Local keep World News API's category (politics as
political, business as economy, else other) and carry no severity; fighting
there shows through the Conflicts reports placed in the area.
- When World News API takes over 12 s to answer a country's first read, Local
reads unavailable until the layer's next refresh (up to 2 minutes), which is
served from that read.
- When World News API takes 10 to 20 s to answer a country's first read, Local
reads unavailable until it is asked again (the next refresh, every 2 minutes
while the tab is shown, or the camera settling in another half-degree cell,
or a section switch), which is served from that read. A read over 20 s fails
and is tried again with the first ask a minute or more later.
- Local needs a World News API key. Its free plan allows 50 points a day; Core
stops at `WORLDNEWSAPI_DAILY_READS` reads (default 30) and Local then says the
daily limit is reached.
+11 -6
View File
@@ -79,9 +79,9 @@ const COUNTRY_FRESH_MS = 30 * 60_000;
// A failed read is not tried again for a minute; what was read before is
// served meanwhile, up to these ages (GDELT's: since an export last came in).
const RETRY_MS = 60_000;
// A read with nothing to serve yet is waited for at most this long, under the
// browser's own 15 s: it goes on, and the next ask is served from it.
const FIRST_ANSWER_MS = 12_000;
// A read with nothing to serve yet is waited for at most this long, well under
// the browser's own 15 s: it goes on, and the next ask is served from it.
const FIRST_ANSWER_MS = 10_000;
const GDELT_STALE_MS = 2 * HOUR_MS;
const LIVE_STALE_MS = HOUR_MS;
const COUNTRY_STALE_MS = 6 * HOUR_MS;
@@ -270,7 +270,8 @@ const spentKey = (key) => ({ status: 503, body: { error: 'key_quota', key } });
* for `/hotspots`, `/feed` and `/outline`, whose queries the proxy has
* already checked against the client's allowlist. `later(run, ms)` runs
* the next GDELT read of a day still filling in (an unref'd timer).
* @param {{env?: () => object, fetchImpl?: typeof fetch, now?: () => number, log?: (message: string) => void, later?: (run: () => void, ms: number) => void}} [options]
* `firstAnswerMs` bounds the wait for a read with nothing to serve yet.
* @param {{env?: () => object, fetchImpl?: typeof fetch, now?: () => number, log?: (message: string) => void, later?: (run: () => void, ms: number) => void, firstAnswerMs?: number}} [options]
*/
export function createKeyedNews({
env = () => process.env,
@@ -376,7 +377,11 @@ export function createKeyedNews({
.finally(() => inFlight.delete(flight)),
);
if (prior.value && at - prior.readAt < staleMs) return prior;
return firstAnswer(inFlight.get(flight), { ...prior, error: null });
// Meanwhile a refused key stays refused; an older failure is not this one.
return firstAnswer(inFlight.get(flight), {
...prior,
error: prior.error?.rejected ? prior.error : null,
});
}
/** An entry stored as the most recently used (the countries are capped). */
@@ -386,7 +391,6 @@ export function createKeyedNews({
while (store.size > MAX_COUNTRIES) store.delete(store.keys().next().value);
}
/** An entry's reports while they may be served, else why there are none. */
/** `read`, waited for at most firstAnswerMs; then `fallback`, as it goes on. */
async function firstAnswer(read, fallback) {
let timer;
@@ -403,6 +407,7 @@ export function createKeyedNews({
}
}
/** An entry's reports while they may be served, else why there are none. */
function served(entry, staleMs, id) {
if (entry.value && now() - entry.readAt < staleMs)
return { reports: entry.value, readAt: entry.readAt };
+103 -31
View File
@@ -1083,34 +1083,106 @@ test("a country's own news keeps World News API's category and no severity, what
assert.equal(read('Parliament session opens', null).category, 'other');
});
test('a first read slower than the browser waits answers unavailable, and the next ask is served from it', async () => {
const fake = upstream();
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const news = createKeyedNews({
env: () => ({ WORLDNEWSAPI_KEY: 'wna-key' }),
fetchImpl: async (url, init) => {
if (new URL(url).host === 'api.worldnewsapi.com') await held;
return fake.fetchImpl(url, init);
},
now: () => NOW,
log: () => {},
later: () => {},
firstAnswerMs: 20,
});
const local = () =>
news.answer('/feed', new URLSearchParams('hotspot=all&at=31.95,35.93'));
assert.deepEqual(await local(), UPSTREAM_ERROR);
// The read went on, and is not asked again.
release();
await new Promise((resolve) => setTimeout(resolve, 5));
const served = await local();
assert.equal(served.status, 200);
assert.deepEqual(
served.body.reports.map((r) => r.id),
['wna-9001', 'wna-9002'],
);
assert.equal(count(fake.calls, 'api.worldnewsapi.com'), 1);
});
test(
'a first read slower than the browser waits answers unavailable, and the next ask is served from it',
{ timeout: 5_000 },
async () => {
const fake = upstream();
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const news = createKeyedNews({
env: () => ({ WORLDNEWSAPI_KEY: 'wna-key' }),
fetchImpl: async (url, init) => {
if (new URL(url).host === 'api.worldnewsapi.com') await held;
return fake.fetchImpl(url, init);
},
now: () => NOW,
log: () => {},
later: () => {},
firstAnswerMs: 20,
});
const local = () =>
news.answer('/feed', new URLSearchParams('hotspot=all&at=31.95,35.93'));
assert.deepEqual(await local(), UPSTREAM_ERROR);
// The read went on, and is not asked again.
release();
await new Promise((resolve) => setTimeout(resolve, 5));
const served = await local();
assert.equal(served.status, 200);
assert.deepEqual(
served.body.reports.map((r) => r.id),
['wna-9001', 'wna-9002'],
);
assert.equal(count(fake.calls, 'api.worldnewsapi.com'), 1);
},
);
test(
'a first GDELT read slower than the browser waits answers unavailable, and the next is served from it',
{ timeout: 5_000 },
async () => {
const fake = upstream();
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const news = createKeyedNews({
env: () => ({}),
fetchImpl: async (url, init) => {
if (new URL(url).host === 'data.gdeltproject.org') await held;
return fake.fetchImpl(url, init);
},
now: () => NOW,
log: () => {},
later: () => {},
firstAnswerMs: 20,
});
const hotspots = () => news.answer('/hotspots', new URLSearchParams());
assert.deepEqual(await hotspots(), UPSTREAM_ERROR);
release();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal((await hotspots()).status, 200);
},
);
test(
'a refused key stays refused while its next read is slow',
{ timeout: 5_000 },
async () => {
let release;
const held = new Promise((resolve) => {
release = resolve;
});
let reads = 0;
const clock = { now: NOW };
const fake = upstream({
worldnews: async () => {
reads++;
if (reads > 1) await held;
return new Response('{"message":"bad key"}', { status: 401 });
},
});
const news = createKeyedNews({
env: () => ({ WORLDNEWSAPI_KEY: 'wna-key' }),
fetchImpl: fake.fetchImpl,
now: () => clock.now,
log: () => {},
later: () => {},
firstAnswerMs: 20,
});
const local = () =>
news.answer('/feed', new URLSearchParams('hotspot=all&at=31.95,35.93'));
const refused = {
status: 503,
body: { error: 'key_rejected', key: 'worldnewsapi' },
};
assert.deepEqual(await local(), refused);
// Past the retry wait it is read again, slowly: still refused meanwhile.
clock.now += 61_000;
assert.deepEqual(await local(), refused);
assert.equal(reads, 2);
release();
},
);
+2
View File
@@ -1428,6 +1428,8 @@ export function createNewsLayer({
}
unavailable = cause?.unavailable === true;
error = cause?.message || FEED_UNAVAILABLE;
// Local is read apart: Conflicts failing leaves it reading.
void refreshInView({ force: inView.active });
return true;
} finally {
signal?.removeEventListener('abort', abort);
+41
View File
@@ -799,6 +799,47 @@ test('without Liveuamap reports or a Local area, nothing is credited', async (t)
assert.deepEqual(credits, []);
});
test('a failed Conflicts read leaves Local reading with each refresh', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const failure = () => new Error('News feed unavailable');
let local = failure();
const { layer } = fixture(t, {
height: 1_000_000,
source: {
getHotspots: async () => {
throw failure();
},
getFeed: async (query) => {
if (!query.at) throw failure();
if (local) throw local;
return {
reports: [report('inside', { relevance: 'minor' })],
nextBefore: null,
};
},
getAreaOutline: async () => ukraine,
},
});
layer.enable();
await layer.update();
layer.setParams({ filters: { section: 'local' } });
await flush();
assert.equal(
layer.getRowControls().feed.inView.error,
'News feed unavailable',
);
// The area's read lands meanwhile; Conflicts still fails.
local = null;
await layer.update();
await flush();
const view = layer.getRowControls().feed;
assert.deepEqual(
view.inView.items.map((item) => item.id),
['inside'],
);
assert.equal(view.error, 'News feed unavailable');
});
test('a failed read keeps the layer on with an error; disable clears the overlay', async (t) => {
const { layer, entries } = fixture(t, {
source: { getFeed: async () => Promise.reject(new Error('boom')) },
+13 -1
View File
@@ -176,12 +176,24 @@ test('reads time out and follow the caller signal', async () => {
);
});
const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 });
// Out of time it reads unavailable, in the app's words, and is retried.
// Out of time it reads unavailable, in the app's words, as retryable.
await assert.rejects(slow.getHotspots(), {
message: 'News feed unavailable',
code: 'timeout',
retryable: true,
});
// The caller's abort stays its own, even once the time is up too.
const late = (_url, { signal }) =>
new Promise((_, reject) => {
setTimeout(() => reject(signal.reason), 40);
});
const leaving = new AbortController();
const gone = createNewsSource({ fetchImpl: late, timeoutMs: 20 }).getFeed(
{},
{ signal: leaving.signal },
);
leaving.abort(new Error('gone'));
await assert.rejects(gone, /gone/);
const caller = new AbortController();
const pending = createNewsSource({ fetchImpl: hang }).getFeed(
{},