Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/base_odds_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ def __init__(self, cache_manager, config_manager=None):
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
Expand Down Expand Up @@ -144,7 +162,7 @@ def get_odds(self, sport: str | None, league: str | None, event_id: str,
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()

Expand Down
6 changes: 4 additions & 2 deletions test/test_base_odds_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
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
Expand Down Expand Up @@ -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

Expand Down
138 changes: 89 additions & 49 deletions test/test_odds_request_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@

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)
Expand All @@ -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

Comment thread
ChuckBuilds marked this conversation as resolved.
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()
Expand All @@ -70,53 +115,48 @@ def test_worst_case_slate_stays_inside_the_budget(self):
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"}
Loading