fix(odds): identify the odds requests to ESPN

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
This commit is contained in:
ChuckBuilds
2026-08-11 15:39:42 -04:00
co-authored by Claude Opus 5
parent bb1a1671ec
commit 0e0b89388d
3 changed files with 112 additions and 52 deletions
+19 -1
View File
@@ -45,6 +45,24 @@ class BaseOddsManager:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.base_url = "https://sports.core.api.espn.com/v2/sports" 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 # Configuration with defaults
self.update_interval = 3600 # 1 hour default self.update_interval = 3600 # 1 hour default
# Well under the plugin executor's 30s operation budget. At 30s a # Well under the plugin executor's 30s operation budget. At 30s a
@@ -144,7 +162,7 @@ class BaseOddsManager:
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" 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}") 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() response.raise_for_status()
raw_data = response.json() raw_data = response.json()
+4 -2
View File
@@ -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 gate (money-line-only odds now format), get_odds_for_games, and
configuration loading. 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 from unittest.mock import MagicMock, patch
@@ -59,7 +61,7 @@ def manager(cache_manager):
@pytest.fixture @pytest.fixture
def mock_get(): 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)]}) m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
yield m yield m
+84 -44
View File
@@ -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 Invisible out of season -- preseason week 1 returns a single game -- and a
Sunday slate is around sixteen. 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 from unittest.mock import Mock
import requests
from src.base_odds_manager import BaseOddsManager from src.base_odds_manager import BaseOddsManager
PLUGIN_BUDGET = 30.0 # PluginExecutor(default_timeout=30.0) 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) 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: class TestRequestTimeout:
def test_leaves_room_in_the_operation_budget(self): def test_leaves_room_in_the_operation_budget(self):
assert _manager().request_timeout < PLUGIN_BUDGET / 2 assert _manager().request_timeout < PLUGIN_BUDGET / 2
def test_the_timeout_is_the_one_actually_used(self): def test_the_timeout_is_the_one_actually_used(self):
m = _manager() m = _manager()
import src.base_odds_manager as mod get = _timing_out(m)
real = mod.requests.get
try:
mod.requests.get = Mock(side_effect=mod.requests.exceptions.Timeout("x"))
m.get_odds("football", "nfl", "401") m.get_odds("football", "nfl", "401")
assert mod.requests.get.call_args.kwargs["timeout"] == m.request_timeout assert get.call_args.kwargs["timeout"] == m.request_timeout
finally:
mod.requests.get = real
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: class TestSlowEspnCannotKillTheUpdate:
def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self): def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self):
m = _manager() m = _manager()
import src.base_odds_manager as mod get = _timing_out(m)
real = mod.requests.get
calls = {"n": 0}
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 for i in range(16): # a full slate, one game at a time
m.get_odds("football", "nfl", "4018730%02d" % i) 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 " "%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): def test_worst_case_slate_stays_inside_the_budget(self):
m = _manager() m = _manager()
@@ -70,53 +115,48 @@ class TestSlowEspnCannotKillTheUpdate:
def test_recovery_is_automatic(self): def test_recovery_is_automatic(self):
m = _manager() m = _manager()
import src.base_odds_manager as mod 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} clock = {"t": 1000.0}
try: try:
mod.time.monotonic = lambda: clock["t"] mod.time.monotonic = lambda: clock["t"]
mod.requests.get = Mock( get = _timing_out(m)
side_effect=mod.requests.exceptions.Timeout("timed out"))
m.get_odds("football", "nfl", "401") m.get_odds("football", "nfl", "401")
assert m._skip_network_until > clock["t"], "breaker did not open" assert m._skip_network_until > clock["t"], "breaker did not open"
clock["t"] += 1 clock["t"] += 1
before = mod.requests.get.call_count before = get.call_count
m.get_odds("football", "nfl", "402") 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 clock["t"] += m._FAILURE_COOLDOWN
m.get_odds("football", "nfl", "403") m.get_odds("football", "nfl", "403")
assert mod.requests.get.call_count > before, "never retried" assert get.call_count > before, "never retried"
finally: 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): def test_a_healthy_fetch_clears_the_breaker(self):
m = _manager() m = _manager()
m._skip_network_until = 0.0 m._skip_network_until = 0.0
m._extract_espn_data = Mock(return_value=None) m._extract_espn_data = Mock(return_value=None)
import src.base_odds_manager as mod _returning(m, {})
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") m.get_odds("football", "nfl", "401")
finally:
mod.requests.get = real
assert m._skip_network_until == 0.0 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): def test_the_stale_cache_fallback_still_works(self):
# The failing request must still hand back whatever was cached; only # The failing request must still hand back whatever was cached; only
# the *subsequent* games skip the network. # the *subsequent* games skip the network.
cache = Mock() cache = Mock()
cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}] cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}]
m = BaseOddsManager(cache_manager=cache, config_manager=None) m = BaseOddsManager(cache_manager=cache, config_manager=None)
import src.base_odds_manager as mod _timing_out(m)
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"} assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
finally:
mod.requests.get = real