From 7fdcc71daf05fe0b87e357532afbfab542998283 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 9 May 2026 11:21:08 +0800 Subject: [PATCH 1/2] feat(tool): expose BLS search + series as economy tools, fix NaN sneak-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third macro source plugged into the economy tool namespace. AI agents can now query BLS catalog + observations the same way they query FRED or EIA, with provider name pinned at the tool boundary. Tools added: - economyBlsSearch — keyword filter against the curated BLS catalog (BLS itself has no search API; the catalog is a hand-maintained list in the typebb fetcher) - economyBlsSeries — observations for one or more BLS series ids, comma-separated for multi-series. Returns long form (one row per date+series_id) — different from FRED's pivot-by-date wide form, documented in the tool description so the LLM doesn't pattern-match incorrectly from FRED. Wiring: - EconomyClientLike: added getBlsSearch + getBlsSeries (SDK already implements them at /survey/bls_*). - tool/economy.ts: BLS_PROVIDER pin + 2 new tool factories. - tool/economy.spec.ts: extended mock and added 9 schema / passthrough / cross-client-isolation cases. Bug fixed (5th in the macro-data string→number family): - bls-series.ts: BLS returns "-" for unavailable observations (e.g. UNRATE 2025-10 dropped during the 2025 lapse in appropriations — footnote text in the wire response confirms). parseFloat("-") returns NaN; the schema rejects NaN with "Expected number, received nan" and the whole batch dies. Same shape as the EIA fix: skip NaN rows instead of pushing them through. Verified end-to-end against the live API; the 2025-10 row is now silently dropped rather than poisoning the result. E2e: added a BLS describe block to market-data.e2e.spec.ts (test-provider button + bls_search via /economy/survey/bls_search + bls_series with explicit Number.isFinite check on every value to catch any future NaN regression). 11 e2e cases now (5 FRED + 3 EIA + 3 BLS) all green against live APIs. Verified live with the user's BLS key: - BlsSearch "CPI": top 5 CPI sub-series (All Items, Core, Food at Home, Gasoline, New Vehicles) - UNRATE Jan 2025–Apr 2026: 4.3-4.5% range, stable; 2025-10 dropout silently skipped - CPI + Nonfarm Payrolls multi: CPI-U 2026-03 = 330.213, NFP 2026-04 = 158.7M total nonfarm payrolls Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/providers/bls/models/bls-series.ts | 7 +- .../__test__/e2e/market-data.e2e.spec.ts | 39 +++++++++ src/domain/market-data/client/types.ts | 6 +- src/tool/economy.spec.ts | 81 ++++++++++++++++++- src/tool/economy.ts | 52 ++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/packages/opentypebb/src/providers/bls/models/bls-series.ts b/packages/opentypebb/src/providers/bls/models/bls-series.ts index 00393b9a..acd5bd94 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 1cdd2aee..4c8e885e 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 111874ea..74ee47a2 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 ab42c31e..76f45174 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 1af9f289..6fa3d202 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. From 39b014a35f13f6d0bd3927702255e111da4d8cf2 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 9 May 2026 11:29:19 +0800 Subject: [PATCH 2/2] feat(opentypebb): classify network-unreachable errors for AI tool callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool errors propagate verbatim into the AI's next turn (Vercel AI SDK forwards the raw thrown value into the tool-result message). When the underlying fetch fails at the network layer — DNS / TLS / proxy / routing — we were throwing the generic OpenBBError("Request failed (TypeError: fetch failed): ..."), which looks indistinguishable to an LLM from a transient flake. Models retry, hit the same wall again, burn tokens, and never tell the user to check their network. This change introduces a NetworkUnreachableError that classifies the fetch-failure case and gives the LLM unambiguous signal: NETWORK_UNREACHABLE: cannot reach from this machine (). 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. Concrete impact on the BLS / EIA / FRED tools just landed: when a user runs OpenAlice behind a Clash-style proxy with fake-ip routing, hosts that aren't in the rule set TCP-connect to the local fake IP, then die in TLS handshake — fetch surfaces this as TypeError("fetch failed") with cause.code = ECONNRESET / SSL_ERROR_SYSCALL / ENOTFOUND. The AI now sees the new classified error instead of "fetch failed", recognizes this as user-fixable, and tells them so on the first failure rather than after N silent retries. Implementation: - packages/opentypebb/src/core/provider/utils/errors.ts: new NetworkUnreachableError extending OpenBBError. Carries the host as a field for callers that want to render structured errors. Message string is intentionally LLM-targeted prose, not just a code. - packages/opentypebb/src/core/provider/utils/helpers.ts: amakeRequest now branches on isFetchNetworkFailure (TypeError + "fetch failed" message — the Node 18+ fetch wrapper's signature for network-layer failures). Cause-code extraction (ENOTFOUND / ECONNREFUSED / ETIMEDOUT / etc.) is included in the AI-visible message for debugging signal. Timeouts (DOMException TimeoutError) and HTTP 4xx/ 5xx (response received but provider-rejected) keep their existing OpenBBError paths — both are recoverable in different ways than network-unreachable. - Tests: 6 new helpers.spec.ts cases covering TypeError("fetch failed"), ENOTFOUND cause preservation, generic non-network errors, HTTP non-2xx, and DOMException timeouts. The classifier is the security- critical bit (false positive => AI gives up retryable work; false negative => AI burns tokens), so the boundary cases are pinned. - Re-exported NetworkUnreachableError from package index so OpenAlice- side code (or any future consumer) can `instanceof` against it for structured handling beyond just reading the message. Verified end-to-end: simulated BLS host being unreachable through the real tool stack (createEconomyTools → SDKEconomyClient → executor → amakeRequest → fetch stubbed to throw TypeError("fetch failed")), caught at the tool execute boundary as NetworkUnreachableError with the AI-targeted message intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/utils/__tests__/helpers.spec.ts | 94 +++++++++++++++++++ .../src/core/provider/utils/errors.ts | 27 ++++++ .../src/core/provider/utils/helpers.ts | 32 ++++++- packages/opentypebb/src/index.ts | 2 +- 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 packages/opentypebb/src/core/provider/utils/__tests__/helpers.spec.ts 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 00000000..2df3e9af --- /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 7b91fa83..6c6092eb 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 d3164e3d..0a91f2f5 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 8848c451..071656e4 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'