fix(odds): don't return the cached no-odds marker as odds (#662)

A game ESPN had no odds for is cached as {"no_odds": True}, so it isn't
re-requested on every update. On the next update get_odds() returned that
marker from the cache as if it were odds: a truthy dict that callers took
to mean the game had some. It's still a cache hit (its ttl decides when to
ask again), but get_odds() 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.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 14:59:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 09103a8a7d
commit d469fe39d2
3 changed files with 29 additions and 4 deletions
+1
View File
@@ -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.
+16 -2
View File
@@ -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]]:
"""
+12 -2
View File
@@ -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',