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
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 00924bb203
commit ec2ecc79c3
3 changed files with 37 additions and 10 deletions
+20
View File
@@ -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()
+4 -3
View File
@@ -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)
+13 -7
View File
@@ -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)