mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 06:08:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d865d41b | ||
|
|
e03fbfe7b1 |
@@ -44,24 +44,6 @@ 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
|
||||||
@@ -162,7 +144,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 = self.session.get(url, timeout=self.request_timeout)
|
response = requests.get(url, timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
raw_data = response.json()
|
||||||
|
|
||||||
|
|||||||
Vendored
+64
-1
@@ -14,6 +14,13 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||||
|
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||||
|
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||||
|
# not tied to the retention policies: those describe how long data stays
|
||||||
|
# useful, and a half-written file was never useful.
|
||||||
|
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CacheStrategyProtocol(Protocol):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -347,6 +354,23 @@ class DiskCache:
|
|||||||
"""Get the cache directory path."""
|
"""Get the cache directory path."""
|
||||||
return self.cache_dir
|
return self.cache_dir
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_orphaned_temp(filename: str) -> bool:
|
||||||
|
"""Whether a name is one of set()'s temp files rather than real data.
|
||||||
|
|
||||||
|
Matches only what this class creates: mkstemp with a prefix of
|
||||||
|
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
|
||||||
|
checked rather than just the leading dot, because this predicate
|
||||||
|
deletes things -- a stray dotfile someone left in the cache directory
|
||||||
|
is not ours to remove, and a completed ".json" never is either.
|
||||||
|
"""
|
||||||
|
if not filename.startswith('.') or filename.endswith('.json'):
|
||||||
|
return False
|
||||||
|
head, sep, suffix = filename.rpartition('.json.')
|
||||||
|
# head is the key (non-empty after the leading dot), suffix is
|
||||||
|
# mkstemp's random component.
|
||||||
|
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||||
|
|
||||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Clean up expired cache files based on retention policies.
|
Clean up expired cache files based on retention policies.
|
||||||
@@ -381,11 +405,50 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Get snapshot of files while holding lock briefly
|
# Get snapshot of files while holding lock briefly
|
||||||
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
|
entries = os.listdir(self.cache_dir)
|
||||||
except OSError as list_error:
|
except OSError as list_error:
|
||||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||||
stats['errors'] += 1
|
stats['errors'] += 1
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
filenames = [f for f in entries if f.endswith('.json')]
|
||||||
|
|
||||||
|
# Sweep temp files abandoned by a write that never finished. set()
|
||||||
|
# removes its own in a finally, so these are the ones where the
|
||||||
|
# process died between mkstemp and os.replace -- a SIGKILL, a lost
|
||||||
|
# restart race, a power cut. Nothing ever collected them: they are
|
||||||
|
# named ".<key>.json.<random>", and the scan above only matches
|
||||||
|
# names ending in .json, so they accumulated indefinitely. Measured
|
||||||
|
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
|
||||||
|
# directory, the oldest six months old.
|
||||||
|
stats['orphan_temp_files_deleted'] = 0
|
||||||
|
for filename in (f for f in entries if self._is_orphaned_temp(f)):
|
||||||
|
# Counted as scanned like any other candidate, so files_deleted
|
||||||
|
# can never exceed files_scanned and the summary line reads
|
||||||
|
# honestly ("77/8864", not "77/0").
|
||||||
|
stats['files_scanned'] += 1
|
||||||
|
path = os.path.join(self.cache_dir, filename)
|
||||||
|
try:
|
||||||
|
# An in-flight write lives for milliseconds, so anything
|
||||||
|
# this old is certainly abandoned rather than in progress.
|
||||||
|
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
|
||||||
|
continue
|
||||||
|
with self._lock:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
os.remove(path)
|
||||||
|
stats['files_deleted'] += 1
|
||||||
|
stats['orphan_temp_files_deleted'] += 1
|
||||||
|
stats['space_freed_bytes'] += size
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue # another sweep got there first
|
||||||
|
except OSError as e:
|
||||||
|
stats['errors'] += 1
|
||||||
|
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
|
||||||
|
|
||||||
|
if stats['orphan_temp_files_deleted']:
|
||||||
|
self.logger.info(
|
||||||
|
"Removed %d abandoned cache temp file(s)",
|
||||||
|
stats['orphan_temp_files_deleted'])
|
||||||
|
|
||||||
# Process files outside the lock to avoid blocking get/set operations
|
# Process files outside the lock to avoid blocking get/set operations
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ 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: requests.Session.get is always patched. The odds path sends
|
No real network: src.base_odds_manager.requests.get is always patched.
|
||||||
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
|
||||||
@@ -61,7 +59,7 @@ def manager(cache_manager):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_get():
|
def mock_get():
|
||||||
with patch('src.base_odds_manager.requests.Session.get') as m:
|
with patch('src.base_odds_manager.requests.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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Tests that abandoned cache temp files get collected.
|
||||||
|
|
||||||
|
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
|
||||||
|
own temp file in a finally. That covers a failed write, but not a process that
|
||||||
|
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
|
||||||
|
ordinary on a Pi. Nothing collected what was left behind: the temp names are
|
||||||
|
".<key>.json.<random>", and the expiry sweep only listed names ending in
|
||||||
|
.json, so they accumulated for as long as the card had been in service.
|
||||||
|
|
||||||
|
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
|
||||||
|
of the entire cache directory -- the oldest six months old.
|
||||||
|
|
||||||
|
The predicate that decides what to delete is tested harder than the sweep
|
||||||
|
itself, because a false positive here destroys real data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStrategy:
|
||||||
|
@staticmethod
|
||||||
|
def get_data_type_from_key(key):
|
||||||
|
return 'default'
|
||||||
|
|
||||||
|
|
||||||
|
POLICIES = {'default': 30}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache(tmp_path):
|
||||||
|
return DiskCache(str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _age(path, seconds):
|
||||||
|
old = time.time() - seconds
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, name, body='{}'):
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(body, encoding='utf-8')
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatCountsAsAnOrphan:
|
||||||
|
@pytest.mark.parametrize('name', [
|
||||||
|
'.weather.json.a1b2c3d4',
|
||||||
|
'.odds_espn_football_nfl_401.json.xyz00000',
|
||||||
|
'.a.json.b',
|
||||||
|
])
|
||||||
|
def test_our_temp_files_are_orphans(self, name):
|
||||||
|
assert DiskCache._is_orphaned_temp(name)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('name', [
|
||||||
|
'weather.json', # real data
|
||||||
|
'.weather.json', # a dotted key that completed
|
||||||
|
'.gitignore', # not ours
|
||||||
|
'.hidden', # not ours
|
||||||
|
'weather.json.bak', # no leading dot: someone else's
|
||||||
|
'.json.abc', # no key between the dot and .json.
|
||||||
|
'.weather.json.', # no random component
|
||||||
|
'notes.txt',
|
||||||
|
])
|
||||||
|
def test_everything_else_is_left_alone(self, name):
|
||||||
|
assert not DiskCache._is_orphaned_temp(name)
|
||||||
|
|
||||||
|
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
|
||||||
|
"""Guard against the predicate and the writer drifting apart."""
|
||||||
|
created = []
|
||||||
|
real = os.replace
|
||||||
|
|
||||||
|
def capture(src, dst):
|
||||||
|
created.append(os.path.basename(src))
|
||||||
|
return real(src, dst)
|
||||||
|
|
||||||
|
import src.cache.disk_cache as mod
|
||||||
|
mod.os.replace = capture
|
||||||
|
try:
|
||||||
|
cache.set('weather', {'v': 1})
|
||||||
|
finally:
|
||||||
|
mod.os.replace = real
|
||||||
|
|
||||||
|
assert created, "set() did not go through the temp-file path"
|
||||||
|
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSweep:
|
||||||
|
def test_an_old_orphan_is_removed(self, cache, tmp_path):
|
||||||
|
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
|
||||||
|
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||||
|
|
||||||
|
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert not p.exists()
|
||||||
|
assert stats['orphan_temp_files_deleted'] == 1
|
||||||
|
assert stats['space_freed_bytes'] >= 5000
|
||||||
|
|
||||||
|
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
|
||||||
|
# The whole risk of this sweep: deleting a temp file another thread is
|
||||||
|
# about to os.replace into place.
|
||||||
|
p = _write(tmp_path, '.weather.json.inflight')
|
||||||
|
|
||||||
|
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert p.exists()
|
||||||
|
|
||||||
|
def test_real_cache_files_survive(self, cache, tmp_path):
|
||||||
|
fresh = _write(tmp_path, 'weather.json')
|
||||||
|
dotted = _write(tmp_path, '.weather.json')
|
||||||
|
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||||
|
|
||||||
|
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert fresh.exists()
|
||||||
|
assert dotted.exists(), "a completed .json was treated as a temp file"
|
||||||
|
|
||||||
|
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
|
||||||
|
keep = _write(tmp_path, '.gitignore')
|
||||||
|
_age(keep, 400 * 86400)
|
||||||
|
|
||||||
|
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert keep.exists()
|
||||||
|
|
||||||
|
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
|
||||||
|
stale = _write(tmp_path, 'old.json')
|
||||||
|
_age(stale, 40 * 86400) # past the 30-day default
|
||||||
|
orphan = _write(tmp_path, '.old.json.zz999999')
|
||||||
|
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||||
|
|
||||||
|
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert not stale.exists()
|
||||||
|
assert not orphan.exists()
|
||||||
|
assert stats['files_deleted'] == 2
|
||||||
|
assert stats['orphan_temp_files_deleted'] == 1
|
||||||
|
|
||||||
|
def test_the_rig_scenario(self, cache, tmp_path):
|
||||||
|
"""76 orphans of assorted ages, none of them reachable before."""
|
||||||
|
for i in range(76):
|
||||||
|
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
|
||||||
|
_age(p, (i + 2) * 86400)
|
||||||
|
keep = _write(tmp_path, 'sched.json')
|
||||||
|
|
||||||
|
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert stats['orphan_temp_files_deleted'] == 76
|
||||||
|
assert keep.exists()
|
||||||
|
assert not list(tmp_path.glob('.sched_*'))
|
||||||
|
# The summary line is "<deleted>/<scanned>", so an orphan that is
|
||||||
|
# deleted but never counted as scanned renders as "76/1".
|
||||||
|
assert stats['files_scanned'] == 77
|
||||||
|
assert stats['files_deleted'] <= stats['files_scanned']
|
||||||
|
|
||||||
|
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
|
||||||
|
p = _write(tmp_path, '.only.json.a1b2c3d4')
|
||||||
|
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||||
|
|
||||||
|
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
|
||||||
|
assert stats['files_deleted'] == 1
|
||||||
|
assert stats['files_scanned'] == 1
|
||||||
|
|
||||||
|
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
|
||||||
|
p = _write(tmp_path, '.weather.json.a1b2c3d4')
|
||||||
|
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||||
|
|
||||||
|
import src.cache.disk_cache as mod
|
||||||
|
real = mod.os.path.getsize
|
||||||
|
|
||||||
|
def vanish(path):
|
||||||
|
if path.endswith('.a1b2c3d4'):
|
||||||
|
os.remove(path)
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
return real(path)
|
||||||
|
|
||||||
|
mod.os.path.getsize = vanish
|
||||||
|
try:
|
||||||
|
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||||
|
finally:
|
||||||
|
mod.os.path.getsize = real
|
||||||
|
|
||||||
|
assert stats['errors'] == 0
|
||||||
@@ -10,15 +10,10 @@ 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)
|
||||||
@@ -30,83 +25,43 @@ 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()
|
||||||
get = _timing_out(m)
|
import src.base_odds_manager as mod
|
||||||
m.get_odds("football", "nfl", "401")
|
real = mod.requests.get
|
||||||
assert get.call_args.kwargs["timeout"] == m.request_timeout
|
try:
|
||||||
|
mod.requests.get = Mock(side_effect=mod.requests.exceptions.Timeout("x"))
|
||||||
|
m.get_odds("football", "nfl", "401")
|
||||||
class TestIdentifiesItselfToEspn:
|
assert mod.requests.get.call_args.kwargs["timeout"] == m.request_timeout
|
||||||
"""ESPN 403s python-requests' default agent, and bare custom tokens.
|
finally:
|
||||||
|
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()
|
||||||
get = _timing_out(m)
|
import src.base_odds_manager as mod
|
||||||
for i in range(16): # a full slate, one game at a time
|
real = mod.requests.get
|
||||||
m.get_odds("football", "nfl", "4018730%02d" % i)
|
calls = {"n": 0}
|
||||||
|
|
||||||
assert get.call_count == 1, (
|
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, (
|
||||||
"%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" % get.call_count)
|
"after the first" % calls["n"])
|
||||||
|
|
||||||
def test_worst_case_slate_stays_inside_the_budget(self):
|
def test_worst_case_slate_stays_inside_the_budget(self):
|
||||||
m = _manager()
|
m = _manager()
|
||||||
@@ -115,48 +70,53 @@ 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_monotonic = mod.time.monotonic
|
real_get, real_monotonic = mod.requests.get, 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"]
|
||||||
get = _timing_out(m)
|
mod.requests.get = Mock(
|
||||||
|
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 = get.call_count
|
before = mod.requests.get.call_count
|
||||||
m.get_odds("football", "nfl", "402")
|
m.get_odds("football", "nfl", "402")
|
||||||
assert get.call_count == before, "should not have retried"
|
assert mod.requests.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 get.call_count > before, "never retried"
|
assert mod.requests.get.call_count > before, "never retried"
|
||||||
finally:
|
finally:
|
||||||
mod.time.monotonic = real_monotonic
|
mod.requests.get, mod.time.monotonic = real_get, 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)
|
||||||
_returning(m, {})
|
import src.base_odds_manager as mod
|
||||||
m.get_odds("football", "nfl", "401")
|
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
|
||||||
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)
|
||||||
_timing_out(m)
|
import src.base_odds_manager as mod
|
||||||
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user