fix(dataflows): degrade gracefully when an optional vendor fails

Optional enrichment vendors (FRED macro, Polymarket events) raised on a bad LLM
indicator, a missing key, or a network blip, which aborted the whole run.

- Router: mark macro_data and prediction_markets optional; a sole-vendor failure
  returns a sentinel instead of re-raising. Core categories still raise.
- FRED: reject a descriptive phrase up front and return guidance instead of
  400ing the API; an unknown series returns a not-found message, not a crash.
This commit is contained in:
Yijia-Xiao
2026-06-21 21:28:59 +00:00
parent 7bb16c5daa
commit ee1ece3347
4 changed files with 90 additions and 16 deletions

View File

@@ -61,6 +61,19 @@ class FredResolutionTests(unittest.TestCase):
self.assertEqual(fred._resolve_series_id("dgs30"), "DGS30")
self.assertEqual(fred._resolve_series_id("MyCustomSeries"), "MYCUSTOMSERIES")
def test_descriptive_phrase_is_rejected(self):
# An LLM phrase (spaces / too long) is not a series ID — reject up front
# with guidance rather than 400ing the API.
for bad in ("bank of japan rate", "the unemployment number", "X" * 31):
with self.assertRaises(ValueError):
fred._resolve_series_id(bad)
def test_get_macro_data_returns_guidance_on_bad_indicator(self):
# Invalid indicator -> actionable message, not a crash (no API call).
out = fred.get_macro_data("bank of japan rate", "2026-01-01")
self.assertIn("FRED", out)
self.assertIn("not a known macro alias", out)
@pytest.mark.unit
class FredConfigTests(unittest.TestCase):
@@ -99,11 +112,13 @@ class FredFormattingTests(unittest.TestCase):
out = fred.get_macro_data("unemployment", "2025-09-30", 30)
self.assertIn("No observations", out)
def test_unknown_series_raises(self):
def test_unknown_series_returns_not_found_message(self):
# A well-formed but unknown series ID returns guidance, not a crash, so
# the run is not aborted over an optional macro lookup.
no_series = {"seriess": []}
with mock.patch.object(fred, "_request", side_effect=_request_stub(meta=no_series)), \
self.assertRaises(ValueError):
fred.get_macro_data("totally_unknown_xyz", "2025-09-30", 30)
with mock.patch.object(fred, "_request", side_effect=_request_stub(meta=no_series)):
out = fred.get_macro_data("totally_unknown_xyz", "2025-09-30", 30)
self.assertIn("not found", out)
def test_long_series_is_truncated_but_change_uses_full_range(self):
# Build > MAX_ROWS observations deterministically.
@@ -157,9 +172,10 @@ class FredRoutingTests(unittest.TestCase):
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
self.assertEqual(out, "MACRO_OK")
def test_not_configured_surfaces_through_router(self):
# With only fred and no key, the router has no fallback and must surface
# the real "not configured" failure rather than masking it.
def test_not_configured_degrades_gracefully(self):
# macro_data is optional: with only fred and no key, the router degrades
# to a sentinel instead of aborting the run — a missing optional key must
# not crash an analysis.
set_config({"data_vendors": {"macro_data": "fred"}})
def _unconfigured(*a, **k):
@@ -169,8 +185,9 @@ class FredRoutingTests(unittest.TestCase):
interface.VENDOR_METHODS,
{"get_macro_indicators": {"fred": _unconfigured}},
clear=False,
), self.assertRaises(fred.FredNotConfiguredError):
interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
):
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
self.assertIn("DATA_UNAVAILABLE", out)
if __name__ == "__main__":

View File

@@ -96,6 +96,28 @@ class VendorRoutingTests(unittest.TestCase):
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertEqual(result, "AV_DATA")
def _route_method(self, method, vendors):
return mock.patch.dict(interface.VENDOR_METHODS, {method: vendors}, clear=False)
def test_optional_category_degrades_instead_of_raising(self):
# An optional enrichment vendor (FRED macro) that raises must NOT abort
# the run — the router returns a sentinel so the analysis proceeds.
set_config({"data_vendors": {"macro_data": "fred"}})
with self._route_method(
"get_macro_indicators", {"fred": _raises(ValueError("FRED 400: bad series"))}
):
result = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-01-01")
self.assertIn("DATA_UNAVAILABLE", result)
self.assertIn("macro_data", result)
def test_core_category_still_raises_on_error(self):
# A core category (single configured vendor) propagates the error so a
# broken primary is loud, not silently degraded.
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
with self._route({"yfinance": _raises(ValueError("boom"))}), \
self.assertRaises(ValueError):
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
if __name__ == "__main__":
unittest.main()