diff --git a/CHANGELOG.md b/CHANGELOG.md index 80070489..88d94bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +- `BaseOddsManager.get_odds()` no longer returns the cached "no odds" marker (`{"no_odds": True}`) as if it were odds. A game ESPN had no odds for is cached that way so it isn't re-requested every update; on the next update the cache hit handed the marker back, and callers saw a truthy dict. It now returns `None` for it, on the cache hit and in the stale-cache fallback after a failed fetch, as the plugins' bundled copies already did. - `web_interface/blueprints/api_v3/plugins.py` (3,285 lines) is split by area into `plugins.py` (installed list, enable/disable, plugin actions), `plugin_store.py`, `plugin_config.py`, `plugin_assets.py`, `plugin_health.py`, `plugin_operations.py` and `plugin_calendar.py`. Pure move: every function body and route decorator is byte-identical, and URLs and endpoint names are unchanged. - Background data fetches retry at one level instead of two. The session adapter retried a connection error three times inside every attempt of the service's own retry loop, so a dead network cost up to 16 connection attempts per request and held one of the few worker threads throughout; now it is the loop's `max_retries + 1` attempts. ESPN date-range chunks, which don't go through that loop and skip a chunk that fails, keep a small connection retry of their own so a brief blip doesn't drop a month from a cached season. diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index c79d04e7..095ce276 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -21,6 +21,11 @@ from typing import Dict, Any, Optional, List from src.common.api_helper import DEFAULT_HTTP_HEADERS + +def _is_no_odds_marker(data: Any) -> bool: + """Whether a cached odds entry is the "ESPN had none" marker, not odds.""" + return isinstance(data, dict) and bool(data.get("no_odds")) + class BaseOddsManager: """ Base class for odds data fetching and management. @@ -126,6 +131,14 @@ class BaseOddsManager: # Per-game chatter, logged on every update of every game on the # slate: debug, not the journal. if cached_data: + # A game ESPN had no odds for is cached as {"no_odds": True} so it + # isn't re-requested every update. That marker is a cache hit -- + # its ttl decides when to ask again -- but it is not odds: returned + # as-is, a caller saw a truthy dict and treated the game as having + # odds. The plugins' bundled copies already did this. + if _is_no_odds_marker(cached_data): + self.logger.debug("Cached no-odds marker for %s", cache_key) + return None self.logger.debug(f"Using cached odds from ESPN for {cache_key}") return cached_data @@ -189,8 +202,9 @@ class BaseOddsManager: "Error fetching odds from ESPN API for %s: %s. Holding off on odds " "for %.0fs so a slate of games does not pay this timeout each.", cache_key, e, self._FAILURE_COOLDOWN) - - return self.cache_manager.get_with_auto_strategy(cache_key) + + cached = self.cache_manager.get_with_auto_strategy(cache_key) + return None if _is_no_odds_marker(cached) else cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ diff --git a/test/test_base_odds_manager.py b/test/test_base_odds_manager.py index 1fa15d18..f8e6adb7 100644 --- a/test/test_base_odds_manager.py +++ b/test/test_base_odds_manager.py @@ -115,16 +115,26 @@ class TestGetOdds: assert result == {'spread': -3.0} mock_get.assert_not_called() - def test_cached_no_odds_sentinel_returned_verbatim( + def test_cached_no_odds_marker_is_a_hit_but_not_odds( self, manager, cache_manager, mock_get): + # The marker keeps a game with no odds from being re-requested every + # update, but it isn't odds: returned as-is, callers saw a truthy + # dict and treated the game as having some. cache_manager.get_with_auto_strategy.return_value = {'no_odds': True} result = manager.get_odds('football', 'nfl', '401') - assert result == {'no_odds': True} + assert result is None mock_get.assert_not_called() assert manager.is_odds_available(result) is False + def test_the_no_odds_marker_is_not_the_stale_fallback_either( + self, manager, cache_manager, mock_get): + mock_get.side_effect = requests.exceptions.ConnectionError("down") + cache_manager.get_with_auto_strategy.side_effect = [None, {'no_odds': True}] + + assert manager.get_odds('football', 'nfl', '401') is None + def test_success_caches_extracted_data_with_interval_ttl( self, manager, cache_manager, mock_get): result = manager.get_odds('football', 'nfl', '401',