feat: harden intelligence runtime and llm providers
This commit is contained in:
@@ -58,7 +58,15 @@ export async function runSource(name, fn, ...args) {
|
||||
timer = setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
|
||||
});
|
||||
const data = await Promise.race([dataPromise, timeoutPromise]);
|
||||
return { name, status: 'ok', durationMs: Date.now() - start, data };
|
||||
const hasError = Boolean(data?.error);
|
||||
const isDegraded = hasError || ['no_credentials', 'degraded', 'failed'].includes(data?.status);
|
||||
return {
|
||||
name,
|
||||
status: isDegraded ? 'degraded' : 'ok',
|
||||
durationMs: Date.now() - start,
|
||||
data,
|
||||
error: hasError ? data.error : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
|
||||
} finally {
|
||||
@@ -127,14 +135,15 @@ export async function fullBriefing() {
|
||||
totalDurationMs: totalMs,
|
||||
sourcesQueried: sources.length,
|
||||
sourcesOk: sources.filter(s => s.status === 'ok').length,
|
||||
sourcesFailed: sources.filter(s => s.status !== 'ok').length,
|
||||
sourcesDegraded: sources.filter(s => s.status === 'degraded').length,
|
||||
sourcesFailed: sources.filter(s => s.status === 'error' || s.status === 'failed').length,
|
||||
},
|
||||
sources: Object.fromEntries(
|
||||
sources.filter(s => s.status === 'ok').map(s => [s.name, s.data])
|
||||
sources.filter(s => s.status === 'ok' || s.status === 'degraded').map(s => [s.name, s.data])
|
||||
),
|
||||
errors: sources.filter(s => s.status !== 'ok').map(s => ({ name: s.name, error: s.error })),
|
||||
errors: sources.filter(s => s.status !== 'ok').map(s => ({ name: s.name, status: s.status, error: s.error || s.data?.message || 'degraded' })),
|
||||
timing: Object.fromEntries(
|
||||
sources.map(s => [s.name, { status: s.status, ms: s.durationMs }])
|
||||
sources.map(s => [s.name, { status: s.status, ms: s.durationMs, error: s.error || null }])
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -35,13 +35,17 @@ async function fetchQuote(symbol) {
|
||||
const url = `${BASE}/${encodeURIComponent(symbol)}?range=5d&interval=1d&includePrePost=false`;
|
||||
const data = await safeFetch(url, {
|
||||
timeout: 8000,
|
||||
retries: 2,
|
||||
source: `YFinance:${symbol}`,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'application/json,text/plain,*/*',
|
||||
},
|
||||
});
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
const result = data?.chart?.result?.[0];
|
||||
if (!result) return null;
|
||||
if (!result) throw new Error(data?.chart?.error?.description || 'Yahoo response missing chart result');
|
||||
|
||||
const meta = result.meta || {};
|
||||
const quotes = result.indicators?.quote?.[0] || {};
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
// Shared fetch utility with timeout, retries, and error handling
|
||||
// Shared fetch utility with timeout, retries, metrics, and error handling
|
||||
|
||||
const fetchMetrics = {
|
||||
requests: 0,
|
||||
ok: 0,
|
||||
failed: 0,
|
||||
bytes: 0,
|
||||
byHost: {},
|
||||
bySource: {},
|
||||
recent: [],
|
||||
};
|
||||
|
||||
function metricBucket(map, key) {
|
||||
if (!map[key]) map[key] = { requests: 0, ok: 0, failed: 0, bytes: 0, lastStatus: null, lastError: null, lastMs: 0 };
|
||||
return map[key];
|
||||
}
|
||||
|
||||
function recordFetchMetric({ url, source = 'unknown', ok, status, bytes, durationMs, error }) {
|
||||
let host = 'unknown';
|
||||
try { host = new URL(url).host; } catch { }
|
||||
fetchMetrics.requests++;
|
||||
fetchMetrics.bytes += bytes || 0;
|
||||
if (ok) fetchMetrics.ok++; else fetchMetrics.failed++;
|
||||
for (const bucket of [metricBucket(fetchMetrics.byHost, host), metricBucket(fetchMetrics.bySource, source)]) {
|
||||
bucket.requests++;
|
||||
bucket.bytes += bytes || 0;
|
||||
bucket.lastStatus = status || null;
|
||||
bucket.lastMs = durationMs || 0;
|
||||
bucket.lastError = error || null;
|
||||
if (ok) bucket.ok++; else bucket.failed++;
|
||||
}
|
||||
fetchMetrics.recent.unshift({ at: new Date().toISOString(), source, host, ok, status, bytes: bytes || 0, durationMs, error: error || null });
|
||||
fetchMetrics.recent = fetchMetrics.recent.slice(0, 100);
|
||||
}
|
||||
|
||||
export function getFetchMetrics() {
|
||||
return JSON.parse(JSON.stringify(fetchMetrics));
|
||||
}
|
||||
|
||||
export async function safeFetch(url, opts = {}) {
|
||||
const { timeout = 15000, retries = 1, headers = {} } = opts;
|
||||
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
|
||||
let lastError;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
@@ -12,14 +50,23 @@ export async function safeFetch(url, opts = {}) {
|
||||
headers: { 'User-Agent': 'Crucix/1.0', ...headers },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const status = res.status;
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
recordFetchMetric({ url, source, ok: false, status, bytes: body.length, durationMs: Date.now() - started, error: `HTTP ${res.status}` });
|
||||
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
recordFetchMetric({ url, source, ok: true, status, bytes: text.length, durationMs: Date.now() - started });
|
||||
const trimmed = text.trim();
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html') || trimmed.startsWith('<!DOCTYPE html') || trimmed.startsWith('<html')) {
|
||||
throw new Error(`Expected JSON but received HTML from ${new URL(url).host}`);
|
||||
}
|
||||
try { return JSON.parse(text); } catch { return { rawText: text.slice(0, 500) }; }
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
recordFetchMetric({ url, source, ok: false, status: null, bytes: 0, durationMs: Date.now() - started, error: e.message });
|
||||
// GDELT needs 5s between requests, others are fine with shorter delays
|
||||
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
|
||||
}
|
||||
@@ -27,6 +74,32 @@ export async function safeFetch(url, opts = {}) {
|
||||
return { error: lastError?.message || 'Unknown error', source: url };
|
||||
}
|
||||
|
||||
export async function safeFetchText(url, opts = {}) {
|
||||
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
|
||||
let lastError;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'Crucix/1.0', ...headers },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const text = await res.text();
|
||||
recordFetchMetric({ url, source, ok: res.ok, status: res.status, bytes: text.length, durationMs: Date.now() - started, error: res.ok ? null : `HTTP ${res.status}` });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
return { text, status: res.status, bytes: text.length };
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
recordFetchMetric({ url, source, ok: false, status: null, bytes: 0, durationMs: Date.now() - started, error: e.message });
|
||||
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
return { error: lastError?.message || 'Unknown error' };
|
||||
}
|
||||
|
||||
export function ago(hours) {
|
||||
return new Date(Date.now() - hours * 3600000).toISOString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user