fix: harden degraded source handling
This commit is contained in:
@@ -52,6 +52,11 @@ export async function getSeries(seriesIds, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function isInvalidKeyResponse(resp) {
|
||||
const message = Array.isArray(resp?.message) ? resp.message.join(' ') : String(resp?.message || resp?.error || '');
|
||||
return Boolean(resp) && /key:.*invalid|invalid.*key|registrationkey/i.test(message);
|
||||
}
|
||||
|
||||
// Extract the latest observation from a BLS series response
|
||||
function latestFromSeries(seriesData) {
|
||||
if (!seriesData?.data?.length) return null;
|
||||
@@ -95,17 +100,25 @@ function momChange(seriesData) {
|
||||
// Briefing — pull latest CPI, unemployment, payrolls
|
||||
export async function briefing(apiKey) {
|
||||
const seriesIds = Object.keys(SERIES);
|
||||
const resp = await getSeries(seriesIds, { apiKey });
|
||||
let resp = await getSeries(seriesIds, { apiKey });
|
||||
let configWarning = null;
|
||||
|
||||
if (apiKey && isInvalidKeyResponse(resp)) {
|
||||
configWarning = 'Configured BLS_API_KEY was rejected; retried with unauthenticated BLS v1 API.';
|
||||
resp = await getSeries(seriesIds, { apiKey: null });
|
||||
}
|
||||
|
||||
if (resp.error) {
|
||||
return { source: 'BLS', error: resp.error, timestamp: new Date().toISOString() };
|
||||
return { source: 'BLS', status: 'degraded', error: resp.error, configWarning, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) {
|
||||
return {
|
||||
source: 'BLS',
|
||||
status: 'degraded',
|
||||
error: resp.message?.[0] || 'BLS API returned no data',
|
||||
rawStatus: resp.status,
|
||||
configWarning,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -155,6 +168,7 @@ export async function briefing(apiKey) {
|
||||
return {
|
||||
source: 'BLS',
|
||||
timestamp: new Date().toISOString(),
|
||||
...(configWarning ? { status: 'degraded', configWarning } : { status: 'ok' }),
|
||||
indicators,
|
||||
signals,
|
||||
};
|
||||
|
||||
@@ -50,29 +50,29 @@ const THRESHOLDS = {
|
||||
|
||||
// Get recent RadNet analytical results (JSON)
|
||||
export async function getAnalyticalResults(opts = {}) {
|
||||
const { rows = 50, startRow = 0 } = opts;
|
||||
const { rows = 50, startRow = 0, timeout = 10000 } = opts;
|
||||
return safeFetch(
|
||||
`${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`,
|
||||
{ timeout: 25000 }
|
||||
{ timeout, retries: 0, source: 'EPA RadNet' }
|
||||
);
|
||||
}
|
||||
|
||||
// Get results filtered by state
|
||||
export async function getResultsByState(state, opts = {}) {
|
||||
const { rows = 25, startRow = 0 } = opts;
|
||||
const { rows = 25, startRow = 0, timeout = 8000 } = opts;
|
||||
return safeFetch(
|
||||
`${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`,
|
||||
{ timeout: 25000 }
|
||||
{ timeout, retries: 0, source: 'EPA RadNet' }
|
||||
);
|
||||
}
|
||||
|
||||
// Get results filtered by analyte type
|
||||
export async function getResultsByAnalyte(analyte, opts = {}) {
|
||||
const { rows = 25, startRow = 0 } = opts;
|
||||
const { rows = 25, startRow = 0, timeout = 8000 } = opts;
|
||||
const encoded = encodeURIComponent(analyte);
|
||||
return safeFetch(
|
||||
`${RADNET_ANALYTICAL}/ANA_TYPE/${encoded}/ROWS/${startRow}:${startRow + rows}/JSON`,
|
||||
{ timeout: 25000 }
|
||||
{ timeout, retries: 0, source: 'EPA RadNet' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,20 @@ export async function briefing() {
|
||||
const signals = [];
|
||||
|
||||
// Fetch recent analytical results (broad pull)
|
||||
const recentData = await getAnalyticalResults({ rows: 100 });
|
||||
const recentData = await getAnalyticalResults({ rows: 80, timeout: 10000 });
|
||||
if (recentData?.error) {
|
||||
return {
|
||||
source: 'EPA RadNet',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'degraded',
|
||||
error: recentData.error,
|
||||
message: 'EPA RadNet broad analytical endpoint did not return current data within the source budget.',
|
||||
readings: [],
|
||||
signals: ['EPA RadNet unavailable — cannot assess official US radiation readings'],
|
||||
monitoredAnalytes: KEY_ANALYTES,
|
||||
thresholds: THRESHOLDS,
|
||||
};
|
||||
}
|
||||
const recentRecords = Array.isArray(recentData) ? recentData : [];
|
||||
|
||||
// Compact all readings
|
||||
@@ -137,15 +150,21 @@ export async function briefing() {
|
||||
readings.push(...allReadings);
|
||||
|
||||
// Also try to pull key analytes specifically
|
||||
const analyteResults = await Promise.all(
|
||||
const analyteResults = await Promise.allSettled(
|
||||
['GROSS BETA', 'IODINE-131', 'CESIUM-137'].map(async analyte => {
|
||||
const data = await getResultsByAnalyte(analyte, { rows: 20 });
|
||||
const data = await getResultsByAnalyte(analyte, { rows: 10, timeout: 6000 });
|
||||
const records = Array.isArray(data) ? data : [];
|
||||
return { analyte, records: records.map(compactReading) };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { analyte, records } of analyteResults) {
|
||||
const analyteErrors = [];
|
||||
for (const item of analyteResults) {
|
||||
if (item.status !== 'fulfilled') {
|
||||
analyteErrors.push(item.reason?.message || 'unknown analyte fetch error');
|
||||
continue;
|
||||
}
|
||||
const { records } = item.value;
|
||||
// Add any records not already in our list
|
||||
for (const r of records) {
|
||||
if (!readings.some(existing =>
|
||||
@@ -196,6 +215,8 @@ export async function briefing() {
|
||||
return {
|
||||
source: 'EPA RadNet',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: analyteErrors.length ? 'degraded' : 'ok',
|
||||
...(analyteErrors.length ? { error: 'epa_analyte_fetch_degraded', analyteErrors: analyteErrors.slice(0, 3) } : {}),
|
||||
totalReadings: readings.length,
|
||||
readings: readings.slice(0, 50), // cap for briefing size
|
||||
stateSummary,
|
||||
|
||||
@@ -10,11 +10,13 @@ const BASE = 'https://api.gdeltproject.org/api/v2';
|
||||
// Search recent global events/articles by keyword
|
||||
export async function searchEvents(query = '', opts = {}) {
|
||||
const {
|
||||
mode = 'ArtList', // ArtList, TimelineVol, TimelineVolInfo, TimelineTone, TimelineLang, TimelineSourceCountry
|
||||
mode = 'artlist',
|
||||
maxRecords = 75,
|
||||
timespan = '24h', // e.g. "24h", "7d", "3m"
|
||||
format = 'json',
|
||||
sortBy = 'DateDesc', // DateDesc, DateAsc, ToneDesc, ToneAsc
|
||||
sortBy = 'datedesc',
|
||||
timeout = 12000,
|
||||
retries = 0,
|
||||
} = opts;
|
||||
|
||||
// If no query, use broad geopolitical terms
|
||||
@@ -28,7 +30,7 @@ export async function searchEvents(query = '', opts = {}) {
|
||||
sort: sortBy,
|
||||
});
|
||||
|
||||
return safeFetch(`${BASE}/doc/doc?${params}`);
|
||||
return safeFetch(`${BASE}/doc/doc?${params}`, { timeout, retries, source: 'GDELT' });
|
||||
}
|
||||
|
||||
// Get tone/sentiment timeline for a topic
|
||||
@@ -60,6 +62,8 @@ export async function geoEvents(query = '', opts = {}) {
|
||||
timespan = '24h',
|
||||
format = 'GeoJSON',
|
||||
maxPoints = 500,
|
||||
timeout = 8000,
|
||||
retries = 0,
|
||||
} = opts;
|
||||
|
||||
const q = query || 'conflict OR military OR protest OR explosion';
|
||||
@@ -71,7 +75,7 @@ export async function geoEvents(query = '', opts = {}) {
|
||||
maxpoints: String(maxPoints),
|
||||
});
|
||||
|
||||
return safeFetch(`${BASE}/geo/geo?${params}`);
|
||||
return safeFetch(`${BASE}/geo/geo?${params}`, { timeout, retries, source: 'GDELT' });
|
||||
}
|
||||
|
||||
// Compact article for briefing
|
||||
@@ -94,7 +98,7 @@ export async function briefing() {
|
||||
// Single broad query to stay within rate limits
|
||||
const all = await searchEvents(
|
||||
'conflict OR military OR economy OR crisis OR war OR sanctions OR tariff OR strike OR outbreak',
|
||||
{ maxRecords: 50, timespan: '24h' }
|
||||
{ maxRecords: 50, timespan: '24h', timeout: 12000, retries: 0 }
|
||||
);
|
||||
|
||||
const articles = (all?.articles || []).map(compactArticle);
|
||||
@@ -108,7 +112,7 @@ export async function briefing() {
|
||||
await delay(5500);
|
||||
let geoPoints = [];
|
||||
try {
|
||||
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' });
|
||||
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h', timeout: 8000, retries: 0 });
|
||||
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
|
||||
lat: f.geometry.coordinates[1],
|
||||
lon: f.geometry.coordinates[0],
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// No auth required. Monitors the Specially Designated Nationals (SDN) list
|
||||
// and consolidated sanctions list for changes.
|
||||
|
||||
import { safeFetch } from '../utils/fetch.mjs';
|
||||
|
||||
const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports';
|
||||
|
||||
// SDN list endpoints
|
||||
@@ -42,21 +40,19 @@ function parseSDNMetadata(xml) {
|
||||
|
||||
// Fetch SDN list metadata (smaller initial chunk via timeout)
|
||||
export async function getSDNMetadata() {
|
||||
// The full SDN XML is large; safeFetch will get the first 500 chars
|
||||
// which should include the header/publish date
|
||||
const data = await safeFetch(SDN_XML_URL, { timeout: 20000 });
|
||||
const data = await fetchXmlSnippet(SDN_XML_URL);
|
||||
return parseSDNMetadata(data);
|
||||
}
|
||||
|
||||
// Fetch advanced SDN data (includes more structured info)
|
||||
export async function getSDNAdvanced() {
|
||||
const data = await safeFetch(SDN_ADVANCED_URL, { timeout: 20000 });
|
||||
const data = await fetchXmlSnippet(SDN_ADVANCED_URL);
|
||||
return parseSDNMetadata(data);
|
||||
}
|
||||
|
||||
// Fetch consolidated list metadata
|
||||
export async function getConsolidatedMetadata() {
|
||||
const data = await safeFetch(CONS_ADVANCED_URL, { timeout: 20000 });
|
||||
const data = await fetchXmlSnippet(CONS_ADVANCED_URL);
|
||||
return parseSDNMetadata(data);
|
||||
}
|
||||
|
||||
@@ -101,15 +97,13 @@ function parseRecentEntries(xml) {
|
||||
|
||||
// Briefing — report on sanctions list status and metadata
|
||||
export async function briefing() {
|
||||
const [sdnMeta, advancedMeta] = await Promise.all([
|
||||
const [sdnMeta, advancedMeta, advancedSnippet] = await Promise.all([
|
||||
getSDNMetadata(),
|
||||
getSDNAdvanced(),
|
||||
fetchXmlSnippet(SDN_ADVANCED_URL, { maxBytes: 256_000 }),
|
||||
]);
|
||||
|
||||
// Try to extract any entries visible in the advanced data
|
||||
const sampleEntries = parseRecentEntries(
|
||||
await safeFetch(SDN_ADVANCED_URL, { timeout: 25000 })
|
||||
);
|
||||
const sampleEntries = parseRecentEntries(advancedSnippet);
|
||||
|
||||
return {
|
||||
source: 'OFAC Sanctions',
|
||||
@@ -136,6 +130,46 @@ export async function briefing() {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchXmlSnippet(url, opts = {}) {
|
||||
const timeout = opts.timeout || 9000;
|
||||
const maxBytes = opts.maxBytes || 128_000;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Crucix/2.0',
|
||||
'Range': `bytes=0-${maxBytes - 1}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok && res.status !== 206) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { error: `HTTP ${res.status}: ${body.slice(0, 160)}` };
|
||||
}
|
||||
if (!res.body?.getReader) {
|
||||
const text = await res.text();
|
||||
return { rawText: text.slice(0, maxBytes) };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
while (bytes < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
bytes += value.byteLength;
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
const buffer = Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), bytes);
|
||||
return { rawText: buffer.toString('utf8', 0, Math.min(bytes, maxBytes)) };
|
||||
} catch (error) {
|
||||
return { error: error.name === 'AbortError' ? `OFAC snippet request timed out after ${timeout}ms` : error.message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Run standalone
|
||||
if (process.argv[1]?.endsWith('ofac.mjs')) {
|
||||
const data = await briefing();
|
||||
|
||||
Reference in New Issue
Block a user