fix: stale-data fallback, per-source rate limits, broken feeds
Cache: add get_stale() for last-known-good fallback when APIs fail. Fetcher: on API failure or tripped circuit breaker, serve stale cached data instead of returning None — dashboards never go blank after first successful fetch. Add per-source rate limits (11 sources configured). Fix Yahoo Finance: compute change_pct from previousClose when regularMarketChangePercent is absent (Yahoo v8 API change). Fix health RSS: replace dead WHO DON/ProMED/CIDRAP URLs with working WHO news, CDC outbreaks, and Outbreak News Today feeds. Fix wildfires: extend FIRMS query from 1-day to 2-day (NRT data lag), include nominal-confidence fires (was filtering to high-only). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0fdd0814d7
commit
e47f0a70b2
@@ -56,11 +56,19 @@ class Cache:
|
|||||||
return None
|
return None
|
||||||
value, expires_at = row
|
value, expires_at = row
|
||||||
if time.time() > expires_at:
|
if time.time() > expires_at:
|
||||||
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
||||||
conn.commit()
|
|
||||||
return None
|
return None
|
||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
|
|
||||||
|
def get_stale(self, key: str) -> Any | None:
|
||||||
|
"""Get cached value even if expired (last-known-good fallback)."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM cache WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
||||||
"""Store a value with TTL in seconds."""
|
"""Store a value with TTL in seconds."""
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Async HTTP fetcher with timeout, retry, rate limiting, and circuit breaker integration.
|
"""Async HTTP fetcher with timeout, retry, rate limiting, and circuit breaker integration.
|
||||||
|
|
||||||
All external HTTP calls in world-intel-mcp go through this module.
|
All external HTTP calls in world-intel-mcp go through this module.
|
||||||
|
Stale-data fallback: when an API call fails, the last-known-good cached
|
||||||
|
response is returned (marked with _stale=True) so dashboards never go blank.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -20,6 +22,24 @@ _yahoo_lock = asyncio.Lock()
|
|||||||
_yahoo_last_call: float = 0.0
|
_yahoo_last_call: float = 0.0
|
||||||
_YAHOO_MIN_INTERVAL = 0.6 # seconds
|
_YAHOO_MIN_INTERVAL = 0.6 # seconds
|
||||||
|
|
||||||
|
# Per-source rate limits (min seconds between calls).
|
||||||
|
# Sources not listed here have no enforced limit.
|
||||||
|
_SOURCE_RATE_LIMITS: dict[str, float] = {
|
||||||
|
"yahoo-finance": 0.6, # unofficial — ~100 req/min safe
|
||||||
|
"opensky": 6.0, # free tier: 10 req/min
|
||||||
|
"coingecko": 2.0, # free tier: 30 calls/min
|
||||||
|
"cloudflare-radar": 3.0, # 20 req/min
|
||||||
|
"reddit": 1.5, # ~60 req/min (be conservative)
|
||||||
|
"nasa-firms": 2.0, # API key: ~1000 req/day
|
||||||
|
"polymarket": 1.0, # be polite
|
||||||
|
"faa": 1.0, # govt API
|
||||||
|
"usgs": 1.0, # generous but be polite
|
||||||
|
"acled": 2.0, # API key based
|
||||||
|
"nga": 2.0, # govt API
|
||||||
|
}
|
||||||
|
_source_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
_source_last_call: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
class Fetcher:
|
class Fetcher:
|
||||||
"""Centralized HTTP fetcher with caching, retries, and circuit breaking."""
|
"""Centralized HTTP fetcher with caching, retries, and circuit breaking."""
|
||||||
@@ -80,20 +100,21 @@ class Fetcher:
|
|||||||
Returns:
|
Returns:
|
||||||
Parsed JSON or None on failure.
|
Parsed JSON or None on failure.
|
||||||
"""
|
"""
|
||||||
# Check circuit breaker
|
# Check cache (live)
|
||||||
if not self.breaker.is_available(source):
|
|
||||||
logger.debug("Circuit open for %s, skipping", source)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
effective_key = cache_key or f"{source}:{url}:{params}"
|
effective_key = cache_key or f"{source}:{url}:{params}"
|
||||||
cached = self.cache.get(effective_key)
|
cached = self.cache.get(effective_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Yahoo rate limiting
|
# Check circuit breaker — fall back to stale data if open
|
||||||
|
if not self.breaker.is_available(source):
|
||||||
|
logger.debug("Circuit open for %s, trying stale cache", source)
|
||||||
|
return self._stale_fallback(effective_key, source)
|
||||||
|
|
||||||
|
# Per-source rate limiting
|
||||||
if yahoo_rate_limit:
|
if yahoo_rate_limit:
|
||||||
await self._yahoo_throttle()
|
await self._yahoo_throttle()
|
||||||
|
await self._source_throttle(source)
|
||||||
|
|
||||||
# Fetch with retries
|
# Fetch with retries
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
@@ -120,10 +141,10 @@ class Fetcher:
|
|||||||
attempt + 1, self.max_retries, source, exc, wait)
|
attempt + 1, self.max_retries, source, exc, wait)
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
# All retries failed
|
# All retries failed — try stale cache before giving up
|
||||||
self.breaker.record_failure(source)
|
self.breaker.record_failure(source)
|
||||||
logger.warning("Fetch failed for %s: %s (url=%s)", source, last_error, url)
|
logger.warning("Fetch failed for %s: %s (url=%s)", source, last_error, url)
|
||||||
return None
|
return self._stale_fallback(effective_key, source)
|
||||||
|
|
||||||
async def get_text(
|
async def get_text(
|
||||||
self,
|
self,
|
||||||
@@ -136,14 +157,16 @@ class Fetcher:
|
|||||||
timeout: float | None = None,
|
timeout: float | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Fetch raw text with caching and circuit breaking."""
|
"""Fetch raw text with caching and circuit breaking."""
|
||||||
if not self.breaker.is_available(source):
|
|
||||||
return None
|
|
||||||
|
|
||||||
effective_key = cache_key or f"{source}:text:{url}:{params}"
|
effective_key = cache_key or f"{source}:text:{url}:{params}"
|
||||||
cached = self.cache.get(effective_key)
|
cached = self.cache.get(effective_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
if not self.breaker.is_available(source):
|
||||||
|
return self._stale_fallback(effective_key, source)
|
||||||
|
|
||||||
|
await self._source_throttle(source)
|
||||||
|
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
@@ -167,7 +190,7 @@ class Fetcher:
|
|||||||
|
|
||||||
self.breaker.record_failure(source)
|
self.breaker.record_failure(source)
|
||||||
logger.warning("Text fetch failed for %s: %s", source, last_error)
|
logger.warning("Text fetch failed for %s: %s", source, last_error)
|
||||||
return None
|
return self._stale_fallback(effective_key, source)
|
||||||
|
|
||||||
async def get_xml(
|
async def get_xml(
|
||||||
self,
|
self,
|
||||||
@@ -180,6 +203,28 @@ class Fetcher:
|
|||||||
"""Fetch XML content (returns raw text for feedparser/ET parsing)."""
|
"""Fetch XML content (returns raw text for feedparser/ET parsing)."""
|
||||||
return await self.get_text(url, source, cache_key, cache_ttl, timeout=timeout)
|
return await self.get_text(url, source, cache_key, cache_ttl, timeout=timeout)
|
||||||
|
|
||||||
|
def _stale_fallback(self, cache_key: str, source: str) -> Any | None:
|
||||||
|
"""Return stale (expired) cached data as last-known-good fallback."""
|
||||||
|
stale = self.cache.get_stale(cache_key)
|
||||||
|
if stale is not None:
|
||||||
|
logger.info("Serving stale cache for %s (key=%s)", source, cache_key)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
async def _source_throttle(self, source: str) -> None:
|
||||||
|
"""Enforce per-source rate limit from _SOURCE_RATE_LIMITS."""
|
||||||
|
min_interval = _SOURCE_RATE_LIMITS.get(source)
|
||||||
|
if min_interval is None:
|
||||||
|
return
|
||||||
|
if source not in _source_locks:
|
||||||
|
_source_locks[source] = asyncio.Lock()
|
||||||
|
async with _source_locks[source]:
|
||||||
|
now = time.time()
|
||||||
|
last = _source_last_call.get(source, 0.0)
|
||||||
|
elapsed = now - last
|
||||||
|
if elapsed < min_interval:
|
||||||
|
await asyncio.sleep(min_interval - elapsed)
|
||||||
|
_source_last_call[source] = time.time()
|
||||||
|
|
||||||
async def _yahoo_throttle(self) -> None:
|
async def _yahoo_throttle(self) -> None:
|
||||||
"""Enforce Yahoo Finance rate limit (600ms between calls)."""
|
"""Enforce Yahoo Finance rate limit (600ms between calls)."""
|
||||||
global _yahoo_last_call
|
global _yahoo_last_call
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ logger = logging.getLogger("world-intel-mcp.sources.health")
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_HEALTH_FEEDS: list[tuple[str, str]] = [
|
_HEALTH_FEEDS: list[tuple[str, str]] = [
|
||||||
("WHO DON", "https://www.who.int/feeds/entity/don/en/rss.xml"),
|
("WHO", "https://www.who.int/rss-feeds/news-english.xml"),
|
||||||
("ProMED", "https://promedmail.org/feed/"),
|
("CDC", "https://tools.cdc.gov/api/v2/resources/media/132608.rss"),
|
||||||
("CIDRAP", "https://www.cidrap.umn.edu/infectious-disease-topics/rss.xml"),
|
("Outbreak News", "https://outbreaknewstoday.com/feed/"),
|
||||||
]
|
]
|
||||||
|
|
||||||
HIGH_CONCERN_PATHOGENS: set[str] = {
|
HIGH_CONCERN_PATHOGENS: set[str] = {
|
||||||
|
|||||||
@@ -83,10 +83,17 @@ async def _fetch_yahoo_quote(fetcher: Fetcher, symbol: str, cache_key: str, cach
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
meta = data["chart"]["result"][0]["meta"]
|
meta = data["chart"]["result"][0]["meta"]
|
||||||
|
price = meta.get("regularMarketPrice")
|
||||||
|
change_pct = meta.get("regularMarketChangePercent")
|
||||||
|
# Yahoo v8 chart often omits regularMarketChangePercent — compute from previousClose
|
||||||
|
if change_pct is None and price is not None:
|
||||||
|
prev = meta.get("previousClose") or meta.get("chartPreviousClose")
|
||||||
|
if prev and prev > 0:
|
||||||
|
change_pct = round(((price - prev) / prev) * 100, 4)
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"price": meta.get("regularMarketPrice"),
|
"price": price,
|
||||||
"change_pct": meta.get("regularMarketChangePercent"),
|
"change_pct": change_pct,
|
||||||
"currency": meta.get("currency"),
|
"currency": meta.get("currency"),
|
||||||
}
|
}
|
||||||
except (KeyError, IndexError, TypeError):
|
except (KeyError, IndexError, TypeError):
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ def _parse_fires_csv(csv_text: str) -> list[dict]:
|
|||||||
if len(fields) <= max(idx_lat, idx_lon, idx_conf):
|
if len(fields) <= max(idx_lat, idx_lon, idx_conf):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
confidence = fields[idx_conf].strip()
|
confidence = fields[idx_conf].strip().lower()
|
||||||
if confidence.lower() not in ("high", "h"):
|
if confidence in ("low", "l"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -171,7 +171,7 @@ async def fetch_wildfires(
|
|||||||
|
|
||||||
async def _fetch_region(region_name: str, bbox: str) -> tuple[str, list[dict] | None]:
|
async def _fetch_region(region_name: str, bbox: str) -> tuple[str, list[dict] | None]:
|
||||||
"""Fetch fires for a single region, return (name, fires_list_or_None)."""
|
"""Fetch fires for a single region, return (name, fires_list_or_None)."""
|
||||||
url = f"{_FIRMS_BASE_URL}/{key}/VIIRS_SNPP_NRT/{bbox}/1"
|
url = f"{_FIRMS_BASE_URL}/{key}/VIIRS_SNPP_NRT/{bbox}/2"
|
||||||
|
|
||||||
csv_text = await fetcher.get_text(
|
csv_text = await fetcher.get_text(
|
||||||
url=url,
|
url=url,
|
||||||
|
|||||||
Reference in New Issue
Block a user