From ec2ecc79c372a2a5bb218a7166eb9e5b19c5309f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 19:58:37 +0000 Subject: [PATCH] fix(router): report a vendor chain with an unavailable vendor as unavailable, not as no data - a vendor that throttled or failed the request never said whether it has the symbol, so another vendor's "no data" no longer speaks for the chain --- tests/test_vendor_errors.py | 20 ++++++++++++++++++++ tests/test_vendor_routing.py | 7 ++++--- tradingagents/dataflows/router.py | 20 +++++++++++++------- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py index 0f65da1f3..f04eec8a1 100644 --- a/tests/test_vendor_errors.py +++ b/tests/test_vendor_errors.py @@ -94,6 +94,26 @@ class RouterHandlesBaseTypesTests(unittest.TestCase): ), self.assertRaises(AlphaVantageNotConfiguredError): router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + def test_an_unavailable_vendor_keeps_absence_from_being_claimed(self): + # The throttled vendor's coverage was never learned, so "no usable data + # from any configured vendor" would be a claim nobody checked. + def throttled(*a, **k): + raise VendorUnavailableError("Yahoo HTTP 429") + + def timed_out(*a, **k): + raise TimeoutError("read timed out") # a vendor that does not type its failures + + def not_covered(*a, **k): + raise NoMarketDataError("AAPL", "AAPL", "not covered") + + for chain in ({"yfinance": throttled, "alpha_vantage": not_covered}, + {"yfinance": not_covered, "alpha_vantage": throttled}, + {"alpha_vantage": timed_out, "yfinance": not_covered}): + set_config({"data_vendors": {"core_stock_apis": ",".join(chain)}}) + with mock.patch.dict(router.VENDOR_METHODS, {"get_stock_data": chain}, clear=False): + out = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + self.assertTrue(out.startswith("DATA_UNAVAILABLE"), out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vendor_routing.py b/tests/test_vendor_routing.py index 85db0ed3f..3d21759bf 100644 --- a/tests/test_vendor_routing.py +++ b/tests/test_vendor_routing.py @@ -72,13 +72,14 @@ class VendorRoutingTests(unittest.TestCase): self.assertEqual(result, "AV_DATA") def test_primary_error_is_logged_not_masked(self): - # #989: primary errors + fallback no-data -> NO_DATA, but the failure - # must be visible in logs (broken primary not hidden). + # #989: a broken primary is not hidden behind a fallback's verdict. It + # never said whether it has the symbol, so the answer is unavailable, + # and the failure is in the logs. set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}}) with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \ self.assertLogs("tradingagents.dataflows.router", level="WARNING") as cm: result = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") - self.assertIn("NO_DATA_AVAILABLE", result) + self.assertTrue(result.startswith("DATA_UNAVAILABLE"), result) joined = "\n".join(cm.output) self.assertIn("boom", joined) # the real error surfaced in logs self.assertIn("yfinance", joined) diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index f8e2b3c2d..b5f0d423f 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -173,7 +173,7 @@ def get_vendor(category: str, method: str = None) -> str: return config.get("data_vendors", {}).get(category, "default") -def vendor_unavailable(method: str, error: VendorUnavailableError) -> str: +def vendor_unavailable(method: str, error: Exception) -> str: """What a call returns when every vendor was throttled or unreachable.""" return ( f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " @@ -211,6 +211,7 @@ def route_to_vendor(method: str, *args, **kwargs): last_no_data: NoMarketDataError | None = None last_unavailable: VendorUnavailableError | None = None + failed: Exception | None = None # a vendor that raised something untyped first_error: Exception | None = None for vendor in vendor_chain: vendor_impl = VENDOR_METHODS[method][vendor] @@ -239,9 +240,19 @@ def route_to_vendor(method: str, *args, **kwargs): logger.warning("Vendor %r failed for %s: %s", vendor, method, e) if first_error is None: first_error = e + failed = e continue - # If any vendor reported "no data", the symbol is genuinely unavailable. + # A vendor that throttled or failed the request never said whether it has + # the symbol, so no other vendor's "no data" can speak for the whole chain: + # report the vendors as the problem, not the instrument. It must not end + # the run either. + if last_unavailable is not None: + return vendor_unavailable(method, last_unavailable) + if failed is not None and last_no_data is not None: + return vendor_unavailable(method, failed) + + # Every vendor that answered reported "no data": the symbol is genuinely unavailable. # Return one explicit, instructive sentinel rather than a vendor-specific # empty string, so the agent reports "unavailable" instead of inventing a # value. This takes precedence over incidental fallback errors. @@ -271,11 +282,6 @@ def route_to_vendor(method: str, *args, **kwargs): # first real error (e.g. the primary vendor's network failure). Optional # enrichment categories degrade to a sentinel instead, so flavour data can't # abort the run. - # Every vendor was throttled or unreachable: that is a fact about the - # vendors, not about the instrument, and it must not end the run. - if last_unavailable is not None: - return vendor_unavailable(method, last_unavailable) - if first_error is not None: if category in OPTIONAL_CATEGORIES: logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)