mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-11 21:58:05 +00:00
fix(odds): identify the odds requests to ESPN (#451)
The odds fetch used a bare requests.get, so it went out as python-requests/x.y -- the one agent ESPN is known to reject. Around 2026-08-04 it began 403ing browser strings and bare custom tokens alike; what it accepts is a token carrying a URL that says who is calling. Every other ESPN caller in the tree already sends that header (src/common/api_helper.py, src/base_classes/data_sources.py); this path was simply missed. It is the worst one to miss. Odds are fetched per live game from inside the live update loop, so its failures are the ones that cost the caller its whole update budget -- the same path the 5s timeout and the cooldown were added to protect. Sent via a session rather than per-call, which also reuses the connection across a slate. Deliberately no retry adapter, unlike api_helper: retries multiply request_timeout, which is 5s precisely to stay inside the 30s operation budget. The existing tests patched the module's requests.get, which this change bypasses -- test_base_odds_manager was consequently reaching the real ESPN and taking 404s. Both files now patch the session, and the new tests pin the agent against api_helper's live value so the two cannot drift apart the next time ESPN moves the goalposts. Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,24 @@ class BaseOddsManager:
|
||||
self.config_manager = config_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
# This path used a bare requests.get, so it identified itself as
|
||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||
# alike; what it accepts is a token with a URL that says who is
|
||||
# calling. Every other ESPN caller in the tree already sends this
|
||||
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
|
||||
# odds path was simply missed, and it is the one whose failures cost
|
||||
# the caller its whole update budget.
|
||||
#
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
# Configuration with defaults
|
||||
self.update_interval = 3600 # 1 hour default
|
||||
@@ -144,7 +162,7 @@ class BaseOddsManager:
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = requests.get(url, timeout=self.request_timeout)
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ is_odds_available's ML-blind truth table, the fixed format_odds_summary
|
||||
gate (money-line-only odds now format), get_odds_for_games, and
|
||||
configuration loading.
|
||||
|
||||
No real network: src.base_odds_manager.requests.get is always patched.
|
||||
No real network: requests.Session.get is always patched. The odds path sends
|
||||
its requests through a session so it can identify itself to ESPN, so patching
|
||||
the module-level requests.get would no longer intercept anything.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -59,7 +61,7 @@ def manager(cache_manager):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get():
|
||||
with patch('src.base_odds_manager.requests.get') as m:
|
||||
with patch('src.base_odds_manager.requests.Session.get') as m:
|
||||
m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
|
||||
yield m
|
||||
|
||||
|
||||
@@ -10,10 +10,15 @@ and the update carrying every game's score was killed:
|
||||
|
||||
Invisible out of season -- preseason week 1 returns a single game -- and a
|
||||
Sunday slate is around sixteen.
|
||||
|
||||
The request now goes through a session that identifies the caller, so the
|
||||
tests patch `manager.session.get` rather than the module's `requests.get`.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import requests
|
||||
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
PLUGIN_BUDGET = 30.0 # PluginExecutor(default_timeout=30.0)
|
||||
@@ -25,43 +30,83 @@ def _manager(cache=None):
|
||||
return BaseOddsManager(cache_manager=cache, config_manager=None)
|
||||
|
||||
|
||||
def _timing_out(manager):
|
||||
"""Point the manager's session at a request that always times out."""
|
||||
manager.session.get = Mock(side_effect=requests.exceptions.Timeout("x"))
|
||||
return manager.session.get
|
||||
|
||||
|
||||
def _returning(manager, payload):
|
||||
resp = Mock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status.return_value = None
|
||||
manager.session.get = Mock(return_value=resp)
|
||||
return manager.session.get
|
||||
|
||||
|
||||
class TestRequestTimeout:
|
||||
def test_leaves_room_in_the_operation_budget(self):
|
||||
assert _manager().request_timeout < PLUGIN_BUDGET / 2
|
||||
|
||||
def test_the_timeout_is_the_one_actually_used(self):
|
||||
m = _manager()
|
||||
import src.base_odds_manager as mod
|
||||
real = mod.requests.get
|
||||
try:
|
||||
mod.requests.get = Mock(side_effect=mod.requests.exceptions.Timeout("x"))
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert mod.requests.get.call_args.kwargs["timeout"] == m.request_timeout
|
||||
finally:
|
||||
mod.requests.get = real
|
||||
get = _timing_out(m)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert get.call_args.kwargs["timeout"] == m.request_timeout
|
||||
|
||||
|
||||
class TestIdentifiesItselfToEspn:
|
||||
"""ESPN 403s python-requests' default agent, and bare custom tokens.
|
||||
|
||||
What it accepts is a token carrying a URL that says who is calling. This
|
||||
path used a bare requests.get and so sent the default -- the one thing
|
||||
known to be rejected. Everything else in the tree that talks to ESPN
|
||||
already sends the header below.
|
||||
"""
|
||||
|
||||
def test_the_user_agent_names_the_project_and_links_to_it(self):
|
||||
ua = _manager().session.headers["User-Agent"]
|
||||
assert "python-requests" not in ua
|
||||
assert "LEDMatrix" in ua
|
||||
assert "github.com/ChuckBuilds/LEDMatrix" in ua
|
||||
|
||||
def test_it_is_the_same_agent_the_rest_of_the_tree_sends(self):
|
||||
# Compared against the live value rather than a copied literal, so the
|
||||
# two cannot drift apart the next time ESPN moves the goalposts.
|
||||
from src.common.api_helper import APIHelper
|
||||
assert (_manager().session.headers["User-Agent"]
|
||||
== APIHelper().session.headers["User-Agent"])
|
||||
|
||||
def test_the_header_reaches_the_request(self):
|
||||
m = _manager()
|
||||
get = _returning(m, {})
|
||||
m._extract_espn_data = Mock(return_value=None)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
# Sent via the session, so it applies without being passed per-call.
|
||||
assert get.call_count == 1
|
||||
assert "User-Agent" in m.session.headers
|
||||
|
||||
def test_no_retry_adapter_multiplies_the_timeout(self):
|
||||
# api_helper mounts a retrying adapter; this path must not, or a 5s
|
||||
# timeout becomes 15s and the budget fix is undone.
|
||||
m = _manager()
|
||||
for adapter in m.session.adapters.values():
|
||||
retries = getattr(adapter, "max_retries", None)
|
||||
assert getattr(retries, "total", 0) in (0, None), (
|
||||
"odds session mounts a retrying adapter (total=%r); retries "
|
||||
"multiply request_timeout" % getattr(retries, "total", None))
|
||||
|
||||
|
||||
class TestSlowEspnCannotKillTheUpdate:
|
||||
def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self):
|
||||
m = _manager()
|
||||
import src.base_odds_manager as mod
|
||||
real = mod.requests.get
|
||||
calls = {"n": 0}
|
||||
get = _timing_out(m)
|
||||
for i in range(16): # a full slate, one game at a time
|
||||
m.get_odds("football", "nfl", "4018730%02d" % i)
|
||||
|
||||
def timeout(*a, **k):
|
||||
calls["n"] += 1
|
||||
raise mod.requests.exceptions.Timeout("timed out")
|
||||
|
||||
try:
|
||||
mod.requests.get = timeout
|
||||
for i in range(16): # a full slate, one game at a time
|
||||
m.get_odds("football", "nfl", "4018730%02d" % i)
|
||||
finally:
|
||||
mod.requests.get = real
|
||||
|
||||
assert calls["n"] == 1, (
|
||||
assert get.call_count == 1, (
|
||||
"%d games each paid the timeout; the breaker should have stopped "
|
||||
"after the first" % calls["n"])
|
||||
"after the first" % get.call_count)
|
||||
|
||||
def test_worst_case_slate_stays_inside_the_budget(self):
|
||||
m = _manager()
|
||||
@@ -70,53 +115,48 @@ class TestSlowEspnCannotKillTheUpdate:
|
||||
def test_recovery_is_automatic(self):
|
||||
m = _manager()
|
||||
import src.base_odds_manager as mod
|
||||
real_get, real_monotonic = mod.requests.get, mod.time.monotonic
|
||||
real_monotonic = mod.time.monotonic
|
||||
clock = {"t": 1000.0}
|
||||
try:
|
||||
mod.time.monotonic = lambda: clock["t"]
|
||||
mod.requests.get = Mock(
|
||||
side_effect=mod.requests.exceptions.Timeout("timed out"))
|
||||
get = _timing_out(m)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until > clock["t"], "breaker did not open"
|
||||
|
||||
clock["t"] += 1
|
||||
before = mod.requests.get.call_count
|
||||
before = get.call_count
|
||||
m.get_odds("football", "nfl", "402")
|
||||
assert mod.requests.get.call_count == before, "should not have retried"
|
||||
assert get.call_count == before, "should not have retried"
|
||||
|
||||
clock["t"] += m._FAILURE_COOLDOWN
|
||||
m.get_odds("football", "nfl", "403")
|
||||
assert mod.requests.get.call_count > before, "never retried"
|
||||
assert get.call_count > before, "never retried"
|
||||
finally:
|
||||
mod.requests.get, mod.time.monotonic = real_get, real_monotonic
|
||||
mod.time.monotonic = real_monotonic
|
||||
|
||||
def test_a_healthy_fetch_clears_the_breaker(self):
|
||||
m = _manager()
|
||||
m._skip_network_until = 0.0
|
||||
m._extract_espn_data = Mock(return_value=None)
|
||||
import src.base_odds_manager as mod
|
||||
real = mod.requests.get
|
||||
try:
|
||||
resp = Mock()
|
||||
resp.json.return_value = {}
|
||||
resp.raise_for_status.return_value = None
|
||||
mod.requests.get = Mock(return_value=resp)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
finally:
|
||||
mod.requests.get = real
|
||||
_returning(m, {})
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until == 0.0
|
||||
|
||||
def test_a_403_opens_the_breaker_rather_than_hammering(self):
|
||||
# raise_for_status raises HTTPError, a RequestException -- so a wrong
|
||||
# or missing agent backs off instead of 403ing once per game.
|
||||
m = _manager()
|
||||
resp = Mock()
|
||||
resp.raise_for_status.side_effect = requests.exceptions.HTTPError("403")
|
||||
m.session.get = Mock(return_value=resp)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until > 0.0
|
||||
|
||||
def test_the_stale_cache_fallback_still_works(self):
|
||||
# The failing request must still hand back whatever was cached; only
|
||||
# the *subsequent* games skip the network.
|
||||
cache = Mock()
|
||||
cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}]
|
||||
m = BaseOddsManager(cache_manager=cache, config_manager=None)
|
||||
import src.base_odds_manager as mod
|
||||
real = mod.requests.get
|
||||
try:
|
||||
mod.requests.get = Mock(
|
||||
side_effect=mod.requests.exceptions.Timeout("timed out"))
|
||||
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
|
||||
finally:
|
||||
mod.requests.get = real
|
||||
_timing_out(m)
|
||||
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
|
||||
|
||||
Reference in New Issue
Block a user