mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 14:18:06 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0b89388d |
@@ -44,6 +44,24 @@ class BaseOddsManager:
|
|||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
m.get_odds("football", "nfl", "401")
|
||||||
try:
|
assert get.call_args.kwargs["timeout"] == m.request_timeout
|
||||||
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
|
class TestIdentifiesItselfToEspn:
|
||||||
finally:
|
"""ESPN 403s python-requests' default agent, and bare custom tokens.
|
||||||
mod.requests.get = real
|
|
||||||
|
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
|
for i in range(16): # a full slate, one game at a time
|
||||||
calls = {"n": 0}
|
m.get_odds("football", "nfl", "4018730%02d" % i)
|
||||||
|
|
||||||
def timeout(*a, **k):
|
assert get.call_count == 1, (
|
||||||
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, (
|
|
||||||
"%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
|
m.get_odds("football", "nfl", "401")
|
||||||
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
|
|
||||||
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
|
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user