diff --git a/packages/opentypebb/src/core/provider/utils/__tests__/helpers.spec.ts b/packages/opentypebb/src/core/provider/utils/__tests__/helpers.spec.ts new file mode 100644 index 000000000..2df3e9af5 --- /dev/null +++ b/packages/opentypebb/src/core/provider/utils/__tests__/helpers.spec.ts @@ -0,0 +1,94 @@ +/** + * amakeRequest network-error classification. + * + * The point of these tests: when fetch fails at the network layer (DNS, + * TLS, routing, refused), the helper must throw a NetworkUnreachableError + * with a "do not retry" hint that surfaces verbatim to AI agents — NOT + * the generic "Request failed (TypeError: fetch failed)" string that + * looks indistinguishable from a transient flake. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { amakeRequest } from '../helpers.js' +import { NetworkUnreachableError, OpenBBError } from '../errors.js' + +const URL_OK = 'https://api.example.com/data' + +describe('amakeRequest — network failure classification', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + }) + afterEach(() => { vi.unstubAllGlobals() }) + + it('classifies TypeError("fetch failed") as NetworkUnreachableError', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(amakeRequest(URL_OK)).rejects.toThrowError(NetworkUnreachableError) + }) + + it('the thrown error message contains NETWORK_UNREACHABLE + the host + a "do not retry" instruction', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')) + try { + await amakeRequest(URL_OK) + throw new Error('should have thrown') + } catch (e) { + const err = e as Error + expect(err.message).toMatch(/NETWORK_UNREACHABLE/) + expect(err.message).toMatch(/api\.example\.com/) + expect(err.message.toLowerCase()).toMatch(/do not retry/i) + } + }) + + it('preserves the underlying cause code when available (ENOTFOUND etc.)', async () => { + const cause = Object.assign(new Error('getaddrinfo ENOTFOUND api.example.com'), { code: 'ENOTFOUND' }) + const wrapped = Object.assign(new TypeError('fetch failed'), { cause }) + fetchMock.mockRejectedValueOnce(wrapped) + try { + await amakeRequest(URL_OK) + throw new Error('should have thrown') + } catch (e) { + const err = e as Error + expect(err).toBeInstanceOf(NetworkUnreachableError) + expect(err.message).toContain('ENOTFOUND') + } + }) + + it('still uses generic OpenBBError for non-network failures (e.g. unhandled non-TypeError throws)', async () => { + fetchMock.mockRejectedValueOnce(new Error('mystery')) + try { + await amakeRequest(URL_OK) + throw new Error('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(OpenBBError) + expect(e).not.toBeInstanceOf(NetworkUnreachableError) + expect((e as Error).message).toMatch(/Request failed/) + } + }) + + it('still uses OpenBBError for HTTP non-2xx (response reached but server rejected)', async () => { + fetchMock.mockResolvedValueOnce(new Response('Forbidden', { status: 403, statusText: 'Forbidden' })) + try { + await amakeRequest(URL_OK) + throw new Error('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(OpenBBError) + expect(e).not.toBeInstanceOf(NetworkUnreachableError) + expect((e as Error).message).toMatch(/HTTP 403/) + } + }) + + it('still uses OpenBBError for timeout (DOMException TimeoutError)', async () => { + const timeoutError = new DOMException('signal timed out', 'TimeoutError') + fetchMock.mockRejectedValueOnce(timeoutError) + try { + await amakeRequest(URL_OK, { timeoutMs: 1 }) + throw new Error('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(OpenBBError) + expect(e).not.toBeInstanceOf(NetworkUnreachableError) + expect((e as Error).message).toMatch(/timed out/) + } + }) +}) diff --git a/packages/opentypebb/src/core/provider/utils/errors.ts b/packages/opentypebb/src/core/provider/utils/errors.ts index 7b91fa835..6c6092ebc 100644 --- a/packages/opentypebb/src/core/provider/utils/errors.ts +++ b/packages/opentypebb/src/core/provider/utils/errors.ts @@ -30,3 +30,30 @@ export class UnauthorizedError extends OpenBBError { this.name = 'UnauthorizedError' } } + +/** + * Raised when the request never reached the provider — DNS failure, TLS + * failure, connection refused, host unreachable, etc. Distinct from + * provider-side errors (HTTP 4xx/5xx, malformed JSON) because the fix + * is on the user's network/proxy, not on the provider, and retrying + * with the same network state is futile. + * + * Surfaced to AI agents with a "do not retry" hint so they don't burn + * tokens on silent re-attempts that all fail the same way. + */ +export class NetworkUnreachableError extends OpenBBError { + readonly host: string + + constructor(host: string, cause: string, original?: unknown) { + super( + `NETWORK_UNREACHABLE: cannot reach ${host} from this machine (${cause}). ` + + `This is a network-layer failure (DNS / routing / TLS / proxy), not a provider error — ` + + `the provider's API may well be operational, the connection from this network cannot complete. ` + + `Do NOT retry the same call; ask the user to check their VPN / proxy routing for this hostname, ` + + `or fall back to a different data source.`, + original, + ) + this.name = 'NetworkUnreachableError' + this.host = host + } +} diff --git a/packages/opentypebb/src/core/provider/utils/helpers.ts b/packages/opentypebb/src/core/provider/utils/helpers.ts index d3164e3d4..0a91f2f59 100644 --- a/packages/opentypebb/src/core/provider/utils/helpers.ts +++ b/packages/opentypebb/src/core/provider/utils/helpers.ts @@ -3,7 +3,33 @@ * Maps to: openbb_core/provider/utils/helpers.py */ -import { OpenBBError } from './errors.js' +import { OpenBBError, NetworkUnreachableError } from './errors.js' + +/** + * Identify a fetch failure that never reached the provider — network + * layer (DNS, routing, TLS, proxy) rather than HTTP-level. + * + * Node 18+ wraps low-level network errors in TypeError("fetch failed") + * with the underlying cause attached as `.cause`. Common cause codes + * we want to flag specifically: ENOTFOUND (DNS), ECONNREFUSED, ETIMEDOUT, + * EHOSTUNREACH, ENETUNREACH, EAI_AGAIN, plus TLS handshake failures + * (UNABLE_TO_VERIFY_LEAF_SIGNATURE, SELF_SIGNED_CERT_IN_CHAIN, etc.). + */ +function isFetchNetworkFailure(error: unknown): boolean { + if (!(error instanceof TypeError)) return false + if (!/fetch failed/i.test(error.message)) return false + return true +} + +function describeFetchCause(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const cause = (error as Error & { cause?: unknown }).cause + if (cause instanceof Error) { + const code = (cause as Error & { code?: string }).code + return code ? `${code}: ${cause.message}` : cause.message + } + return error.message +} /** Query-param names that carry secrets and must not appear in error logs. */ const SECRET_QUERY_KEYS = new Set([ @@ -76,6 +102,10 @@ export async function amakeRequest( if (error instanceof DOMException && error.name === 'TimeoutError') { throw new OpenBBError(`Request timed out after ${timeoutMs}ms: ${safeUrl}`) } + if (isFetchNetworkFailure(error)) { + const host = (() => { try { return new URL(url).host } catch { return safeUrl } })() + throw new NetworkUnreachableError(host, describeFetchCause(error), error) + } throw new OpenBBError(`Request failed (${describeCause(error)}): ${safeUrl}`, error) } diff --git a/packages/opentypebb/src/index.ts b/packages/opentypebb/src/index.ts index 8848c451f..071656e4b 100644 --- a/packages/opentypebb/src/index.ts +++ b/packages/opentypebb/src/index.ts @@ -34,7 +34,7 @@ export { Router, type CommandDef, type CommandHandler } from './core/app/router. // Utilities export { amakeRequest, applyAliases, replaceEmptyStrings, buildQueryString } from './core/provider/utils/helpers.js' -export { OpenBBError, EmptyDataError, UnauthorizedError } from './core/provider/utils/errors.js' +export { OpenBBError, EmptyDataError, UnauthorizedError, NetworkUnreachableError } from './core/provider/utils/errors.js' // App loader — convenience functions to create a fully-loaded system export { createRegistry, createExecutor, loadAllRouters } from './core/api/app-loader.js' diff --git a/packages/opentypebb/src/providers/bls/models/bls-series.ts b/packages/opentypebb/src/providers/bls/models/bls-series.ts index 00393b9a9..acd5bd94e 100644 --- a/packages/opentypebb/src/providers/bls/models/bls-series.ts +++ b/packages/opentypebb/src/providers/bls/models/bls-series.ts @@ -62,6 +62,11 @@ export class BLSBlsSeriesFetcher extends Fetcher { const results: Record[] = [] for (const series of data.Results?.series ?? []) { for (const obs of series.data) { + // BLS returns '-' (or other non-numeric) for unavailable observations — + // e.g. the 2025-10 UNRATE dropout due to "lapse in appropriations". + // Skip rather than push NaN, which the schema would reject. + const value = parseFloat(obs.value) + if (Number.isNaN(value)) continue // Convert period M01..M12 to month const monthMatch = obs.period.match(/M(\d{2})/) const month = monthMatch ? monthMatch[1] : '01' @@ -69,7 +74,7 @@ export class BLSBlsSeriesFetcher extends Fetcher { results.push({ date, series_id: series.seriesID, - value: parseFloat(obs.value), + value, period: obs.period, }) } diff --git a/src/domain/market-data/__test__/e2e/market-data.e2e.spec.ts b/src/domain/market-data/__test__/e2e/market-data.e2e.spec.ts index 1cdd2aee3..4c8e885e9 100644 --- a/src/domain/market-data/__test__/e2e/market-data.e2e.spec.ts +++ b/src/domain/market-data/__test__/e2e/market-data.e2e.spec.ts @@ -118,3 +118,42 @@ describe('market-data e2e — EIA via webui routes', () => { console.log(` Crude oil stocks latest: ${last.date} = ${last.value} ${last.unit}`) }) }) + +describe('market-data e2e — BLS via webui routes', () => { + beforeEach(({ skip }) => { if (!hasProviderKey(t.config, 'bls')) skip('no bls key in config') }) + + it('test-provider endpoint reports ok for valid bls key', async () => { + const key = t.config.marketData.providerKeys!.bls! + const r = await postJson(t.app, '/api/market-data/test-provider', { provider: 'bls', key }) + expect(r.status).toBe(200) + if (!r.data.ok) console.log(' test-provider error:', r.data.error) + expect(r.data.ok).toBe(true) + }) + + it('bls_search returns curated series matching keyword', async () => { + // BLS doesn't have a real search API — bls-search.ts hardcodes a curated list. + // The probe verifies the wiring is intact; the catalog itself is provider-side. + const rows = await getJson(t.app, '/api/market-data-v1/economy/survey/bls_search?provider=bls&query=unemployment') + expect(rows.length).toBeGreaterThan(0) + const seriesIds = rows.map(r => r.series_id) + expect(seriesIds).toContain('LNS14000000') + console.log(` BLS search "unemployment": ${seriesIds.join(', ')}`) + }) + + it('bls_series returns observations + skips missing periods', async () => { + // Catches the parseFloat-NaN bug — BLS returns '-' for unavailable + // observations (e.g. 2025-10 UNRATE during government shutdown). + // Without the NaN-skip the schema would reject the whole batch. + const rows = await getJson( + t.app, + '/api/market-data-v1/economy/survey/bls_series?provider=bls&symbol=LNS14000000&start_date=2024-01-01', + ) + expect(rows.length).toBeGreaterThan(20) + expect(typeof rows[0].value).toBe('number') + expect(rows.every(r => Number.isFinite(r.value))).toBe(true) // no NaN sneak through + const last = rows[rows.length - 1] + const lastDate = new Date(last.date) + expect(lastDate.getFullYear()).toBeGreaterThanOrEqual(2025) + console.log(` UNRATE latest: ${last.date} = ${last.value}%`) + }) +}) diff --git a/src/domain/market-data/client/types.ts b/src/domain/market-data/client/types.ts index 111874eac..74ee47a27 100644 --- a/src/domain/market-data/client/types.ts +++ b/src/domain/market-data/client/types.ts @@ -27,8 +27,9 @@ import type { OptionsChainsData, OptionsSnapshotsData, OptionsUnusualData, // Commodity CommoditySpotPriceData, PetroleumStatusReportData, ShortTermEnergyOutlookData, - // Economy (FRED) + // Economy (FRED + BLS) FredSearchData, FredSeriesData, FredRegionalData, + BlsSearchData, BlsSeriesData, } from '@traderalice/opentypebb' export interface EquityClientLike { @@ -94,6 +95,9 @@ export interface EconomyClientLike { fredSearch(params: Record): Promise fredSeries(params: Record): Promise fredRegional(params: Record): Promise + // BLS — Bureau of Labor Statistics, mounted under /economy/survey/* upstream + getBlsSearch(params: Record): Promise + getBlsSeries(params: Record): Promise } export interface DerivativesClientLike { diff --git a/src/tool/economy.spec.ts b/src/tool/economy.spec.ts index ab42c31e5..76f45174b 100644 --- a/src/tool/economy.spec.ts +++ b/src/tool/economy.spec.ts @@ -18,6 +18,8 @@ function makeMockEconomyClient(): EconomyClientLike { fredSearch: vi.fn(async () => []), fredSeries: vi.fn(async () => []), fredRegional: vi.fn(async () => []), + getBlsSearch: vi.fn(async () => []), + getBlsSeries: vi.fn(async () => []), } } @@ -160,6 +162,81 @@ describe('createEconomyTools — economyFredRegional', () => { }) }) +describe('createEconomyTools — economyBlsSearch', () => { + let client: EconomyClientLike + let commodity: CommodityClientLike + let tools: ReturnType + + beforeEach(() => { + client = makeMockEconomyClient() + commodity = makeMockCommodityClient() + tools = createEconomyTools(client, commodity) + }) + + it('passes query through and pins provider to bls', async () => { + await exec(tools.economyBlsSearch, { query: 'unemployment' }) + expect(client.getBlsSearch).toHaveBeenCalledWith({ query: 'unemployment', provider: 'bls' }) + }) + + it('forwards optional limit when provided', async () => { + await exec(tools.economyBlsSearch, { query: 'CPI', limit: 5 }) + expect(client.getBlsSearch).toHaveBeenCalledWith({ query: 'CPI', provider: 'bls', limit: 5 }) + }) + + it('schema rejects missing query', () => { + const schema = (tools.economyBlsSearch as any).inputSchema + expect(schema.safeParse({}).success).toBe(false) + }) +}) + +describe('createEconomyTools — economyBlsSeries', () => { + let client: EconomyClientLike + let commodity: CommodityClientLike + let tools: ReturnType + + beforeEach(() => { + client = makeMockEconomyClient() + commodity = makeMockCommodityClient() + tools = createEconomyTools(client, commodity) + }) + + it('passes single symbol + provider', async () => { + await exec(tools.economyBlsSeries, { symbol: 'LNS14000000' }) + expect(client.getBlsSeries).toHaveBeenCalledWith({ symbol: 'LNS14000000', provider: 'bls' }) + }) + + it('passes comma-separated symbols verbatim', async () => { + await exec(tools.economyBlsSeries, { symbol: 'LNS14000000,CUUR0000SA0' }) + expect(client.getBlsSeries).toHaveBeenCalledWith({ symbol: 'LNS14000000,CUUR0000SA0', provider: 'bls' }) + }) + + it('forwards date range when provided', async () => { + await exec(tools.economyBlsSeries, { + symbol: 'LNS14000000', start_date: '2020-01-01', end_date: '2024-12-31', + }) + expect(client.getBlsSeries).toHaveBeenCalledWith({ + symbol: 'LNS14000000', provider: 'bls', + start_date: '2020-01-01', end_date: '2024-12-31', + }) + }) + + it('does NOT touch commodityClient', async () => { + await exec(tools.economyBlsSeries, { symbol: 'LNS14000000' }) + expect(commodity.getEnergyOutlook).not.toHaveBeenCalled() + expect(commodity.getPetroleumStatus).not.toHaveBeenCalled() + }) + + it('schema rejects missing symbol', () => { + const schema = (tools.economyBlsSeries as any).inputSchema + expect(schema.safeParse({}).success).toBe(false) + }) + + it('propagates client errors', async () => { + ;(client.getBlsSeries as any).mockRejectedValueOnce(new Error('bls 503')) + await expect(exec(tools.economyBlsSeries, { symbol: 'LNS14000000' })).rejects.toThrow('bls 503') + }) +}) + describe('createEconomyTools — economyEnergyOutlook', () => { let economy: EconomyClientLike let commodity: CommodityClientLike @@ -237,9 +314,11 @@ describe('createEconomyTools — economyPetroleumStatus', () => { }) describe('createEconomyTools — toolset surface', () => { - it('exposes the FRED + EIA tools', () => { + it('exposes the FRED + BLS + EIA tools', () => { const tools = createEconomyTools(makeMockEconomyClient(), makeMockCommodityClient()) expect(Object.keys(tools).sort()).toEqual([ + 'economyBlsSearch', + 'economyBlsSeries', 'economyEnergyOutlook', 'economyFredRegional', 'economyFredSearch', diff --git a/src/tool/economy.ts b/src/tool/economy.ts index 1af9f2891..6fa3d202b 100644 --- a/src/tool/economy.ts +++ b/src/tool/economy.ts @@ -23,6 +23,7 @@ import type { EconomyClientLike, CommodityClientLike } from '@/domain/market-dat const FRED_PROVIDER = 'federal_reserve' const EIA_PROVIDER = 'eia' +const BLS_PROVIDER = 'bls' export function createEconomyTools( economyClient: EconomyClientLike, @@ -102,6 +103,57 @@ per-capita income).`, }, }), + economyBlsSearch: tool({ + description: `Search the Bureau of Labor Statistics catalog for a series_id by keyword. + +Returns a small curated list of common BLS series matching the query (CPI, +unemployment rate, nonfarm payrolls, JOLTS, PPI, productivity, etc.). BLS +itself does not expose a search API — this is a hand-maintained catalog +on the provider side, so coverage is intentionally narrow rather than +exhaustive. + +Once you have the series_id, pass it to economyBlsSeries to get observations.`, + inputSchema: z.object({ + query: z.string().describe('Keyword to filter the BLS catalog, e.g. "unemployment", "CPI", "JOLTS"'), + limit: z.number().int().positive().optional().describe('Max results to return (default: 100)'), + }), + execute: async ({ query, limit }) => { + const params: Record = { query, provider: BLS_PROVIDER } + if (limit !== undefined) params.limit = limit + return await economyClient.getBlsSearch(params) + }, + }), + + economyBlsSeries: tool({ + description: `Fetch observations for one or more BLS series. + +Pass a single series_id (e.g. "LNS14000000" for unemployment rate) or +comma-separated ids (e.g. "LNS14000000,CUUR0000SA0") to retrieve multiple +series at once. + +NOTE: Unlike economyFredSeries which pivots multi-series into one row per +date with a column per series, BLS results are returned in long form: +one row per (date, series_id) with a single \`value\` column. Filter or +group client-side if you need a pivot. + +Default time window is the last 10 years if no date range is given. BLS +returns null/missing for unavailable observations (e.g. months affected +by funding lapses) — those rows are dropped before returning. + +If you don't know the series_id, call economyBlsSearch first.`, + inputSchema: z.object({ + symbol: z.string().describe('BLS series id, or comma-separated ids for multi-series'), + start_date: z.string().optional().describe('Start date YYYY-MM-DD (only year is used; default: 10 years ago)'), + end_date: z.string().optional().describe('End date YYYY-MM-DD (only year is used; default: current year)'), + }), + execute: async ({ symbol, start_date, end_date }) => { + const params: Record = { symbol, provider: BLS_PROVIDER } + if (start_date !== undefined) params.start_date = start_date + if (end_date !== undefined) params.end_date = end_date + return await economyClient.getBlsSeries(params) + }, + }), + economyEnergyOutlook: tool({ description: `Fetch the EIA Short-Term Energy Outlook (STEO) for a given category.