diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 50b6fe63..4b455069 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -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() diff --git a/test/test_base_odds_manager.py b/test/test_base_odds_manager.py index 3eb591e3..e53a5036 100644 --- a/test/test_base_odds_manager.py +++ b/test/test_base_odds_manager.py @@ -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 diff --git a/test/test_odds_request_budget.py b/test/test_odds_request_budget.py index 233780fb..6289a18f 100644 --- a/test/test_odds_request_budget.py +++ b/test/test_odds_request_budget.py @@ -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"}