Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>

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/)
}
})
})
27 changes: 27 additions & 0 deletions packages/opentypebb/src/core/provider/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
32 changes: 31 additions & 1 deletion packages/opentypebb/src/core/provider/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -76,6 +102,10 @@ export async function amakeRequest<T = unknown>(
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)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opentypebb/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion packages/opentypebb/src/providers/bls/models/bls-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,19 @@ export class BLSBlsSeriesFetcher extends Fetcher {
const results: Record<string, unknown>[] = []
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'
const date = `${obs.year}-${month}-01`
results.push({
date,
series_id: series.seriesID,
value: parseFloat(obs.value),
value,
period: obs.period,
})
}
Expand Down
39 changes: 39 additions & 0 deletions src/domain/market-data/__test__/e2e/market-data.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`)
})
})
6 changes: 5 additions & 1 deletion src/domain/market-data/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -94,6 +95,9 @@ export interface EconomyClientLike {
fredSearch(params: Record<string, unknown>): Promise<FredSearchData[]>
fredSeries(params: Record<string, unknown>): Promise<FredSeriesData[]>
fredRegional(params: Record<string, unknown>): Promise<FredRegionalData[]>
// BLS — Bureau of Labor Statistics, mounted under /economy/survey/* upstream
getBlsSearch(params: Record<string, unknown>): Promise<BlsSearchData[]>
getBlsSeries(params: Record<string, unknown>): Promise<BlsSeriesData[]>
}

export interface DerivativesClientLike {
Expand Down
81 changes: 80 additions & 1 deletion src/tool/economy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => []),
}
}

Expand Down Expand Up @@ -160,6 +162,81 @@ describe('createEconomyTools — economyFredRegional', () => {
})
})

describe('createEconomyTools — economyBlsSearch', () => {
let client: EconomyClientLike
let commodity: CommodityClientLike
let tools: ReturnType<typeof createEconomyTools>

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<typeof createEconomyTools>

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
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading