mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user