mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Carry server structural rejections into Radio diagnostics
The proxy now skips malformed upstream rows individually but counts them, capped at 10,000, in the catalog metadata and the /stations envelope (rejectedStationCount). The count never fails health policy, so an otherwise healthy generation is still accepted. The client validates the field, adds its own rejected rows after admission, and reports degraded coverage when either side skipped rows. Adds an end-to-end proxy-to-client test and aligns DATA_SOURCES.md and CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
82190d531a
commit
f93982f55e
9 files changed
+159
-23
No files matched your search
+36
-2
@@ -723,13 +723,47 @@ test('unusable directory responses preserve warm client state atomically', async
|
||||
assert.equal(getRadioUIState().stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
|
||||
// Server-side structural rejections are combined with client rejections
|
||||
// after admission; the healthy generation is still accepted.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 2,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 3);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 10_000,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
radioLayer.destroy();
|
||||
radioLayer.init(viewer);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 0);
|
||||
assert.equal(getRadioUIState().degraded, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from '../../vite.config.js';
|
||||
import { rankRadioStationsForRequest } from './radio.js';
|
||||
import {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
radioLayer,
|
||||
rankRadioStationsForRequest,
|
||||
} from './radio.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
|
||||
|
||||
const UUID = '12345678-1234-4234-8234-123456789abc';
|
||||
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
@@ -541,3 +548,54 @@ test('case expansion is capped after tag normalization in the portable and serve
|
||||
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
|
||||
assert.deepEqual(row.tags.slice(1), ['news radio']);
|
||||
});
|
||||
|
||||
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const middleware = createProxy({
|
||||
now: () => Date.now(),
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const rows = healthyRowsForQuery(url);
|
||||
if (queryTag(url)) return responseJson(rows);
|
||||
// 400 valid broad rows, one row missing its required name, and one
|
||||
// schema-valid row that normalization filters by policy (not counted).
|
||||
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
|
||||
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.degradedReason, null);
|
||||
assert.equal(body.acceptedGeneration, 1);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
|
||||
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
const state = getRadioUIState();
|
||||
assert.equal(state.stationCount, body.stations.length);
|
||||
assert.equal(state.rejectedStationCount, 1);
|
||||
assert.equal(state.degraded, true);
|
||||
assert.equal(state.stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
} from './policy.js';
|
||||
|
||||
// Directory errors stay English in layer state and stats; the layer row and
|
||||
@@ -64,6 +65,18 @@ export function createIngestion({
|
||||
typeof body.degraded !== 'boolean'
|
||||
)
|
||||
throw new Error('Radio directory freshness metadata was malformed');
|
||||
// Rows the server skipped as structurally malformed. Older envelopes
|
||||
// omit the field; a present value must be a bounded count.
|
||||
const serverRejectedStationCount =
|
||||
body.rejectedStationCount === undefined
|
||||
? 0
|
||||
: body.rejectedStationCount;
|
||||
if (
|
||||
!Number.isSafeInteger(serverRejectedStationCount) ||
|
||||
serverRejectedStationCount < 0 ||
|
||||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
|
||||
)
|
||||
throw new Error('Radio directory diagnostic metadata was malformed');
|
||||
const rows = body.stations;
|
||||
const acceptedRows = rows.filter(
|
||||
parts.catalogModel.isValidRadioDirectoryStation,
|
||||
@@ -142,9 +155,12 @@ export function createIngestion({
|
||||
}
|
||||
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
|
||||
}
|
||||
// Combine server-side structural rejections with rows this client
|
||||
// refused. The count is diagnostic: it marks coverage degraded but does
|
||||
// not undo the generation admitted above.
|
||||
layerState._rejectedStationCount = Math.min(
|
||||
1000000,
|
||||
rows.length - acceptedRows.length,
|
||||
RADIO_REJECTED_STATION_COUNT_MAX,
|
||||
serverRejectedStationCount + rows.length - acceptedRows.length,
|
||||
);
|
||||
layerState._degraded =
|
||||
body.degraded || layerState._rejectedStationCount > 0;
|
||||
|
||||
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Bound for the server's `rejectedStationCount` envelope field and the
|
||||
// combined client counter; matches server/providers/radio/constants.js.
|
||||
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
|
||||
|
||||
export const RADIO_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
|
||||
Reference in new issue
Block a user