mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(fetch): one ESPN scoreboard cache key and a max-age response cache (fetch service stage 2) (#728)
Fetch service stage 2: one ESPN scoreboard cache key shared across the sports base classes (legacy keys still read), and a max-age response cache in the fetch service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+36
-11
@@ -205,27 +205,52 @@ class TestCacheLifetimeWithRealCacheManager:
|
||||
|
||||
class TestEspnHelpers:
|
||||
@freeze_time('2026-08-07')
|
||||
def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper, cache):
|
||||
# The canonical key (fetch service stage 2), shared with every other
|
||||
# consumer of the scoreboard; the old key is only read as a fallback.
|
||||
cache.get_cached_data.return_value = None
|
||||
helper._get = Mock(return_value={'ok': 1})
|
||||
|
||||
result = helper.fetch_espn_scoreboard('football', 'nfl')
|
||||
|
||||
assert result == {'ok': 1}
|
||||
helper.get.assert_called_once_with(
|
||||
helper._get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
params={'dates': '20260807', 'limit': ESPN_MAX_LIMIT},
|
||||
cache_key='espn_football_nfl_20260807',
|
||||
cache_ttl=300,
|
||||
{'dates': '20260807', 'limit': ESPN_MAX_LIMIT},
|
||||
None, None, None, 300, 300,
|
||||
)
|
||||
read = [c.args[0] for c in cache.get_cached_data.call_args_list]
|
||||
assert read == ['espn_scoreboard_football_nfl_20260807', 'espn_football_nfl_20260807']
|
||||
cache.set.assert_called_once_with('espn_scoreboard_football_nfl_20260807', {'ok': 1})
|
||||
|
||||
def test_fetch_espn_scoreboard_explicit_date(self, helper):
|
||||
helper.get = Mock(return_value=None)
|
||||
def test_fetch_espn_scoreboard_explicit_date(self, helper, cache):
|
||||
cache.get_cached_data.return_value = None
|
||||
helper._get = Mock(return_value=None)
|
||||
|
||||
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
|
||||
|
||||
kwargs = helper.get.call_args.kwargs
|
||||
assert kwargs['params'] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT}
|
||||
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
|
||||
args = helper._get.call_args.args
|
||||
assert args[1] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT}
|
||||
cache.set.assert_not_called() # a failed fetch caches nothing
|
||||
|
||||
def test_fetch_espn_scoreboard_an_explicit_key_works_as_before(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115', cache_key='mine')
|
||||
|
||||
assert helper.get.call_args.kwargs['cache_key'] == 'mine'
|
||||
|
||||
def test_fetch_espn_scoreboard_reads_the_old_key_after_an_upgrade(self, helper, cache):
|
||||
import time as _time
|
||||
|
||||
records = {'espn_basketball_nba_20250115': {
|
||||
'timestamp': _time.time() - 10, 'ttl': 300, 'data': {'events': ['old']}}}
|
||||
cache.get_cached_data.side_effect = lambda key, **kw: records.get(key)
|
||||
helper._get = Mock()
|
||||
|
||||
assert helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') == {
|
||||
'events': ['old']}
|
||||
helper._get.assert_not_called()
|
||||
|
||||
def test_fetch_espn_standings_url_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
"""One cache key per ESPN scoreboard (fetch service stage 2).
|
||||
|
||||
- every core caller names a scoreboard with the same key;
|
||||
- the keys each consumer used before are read as a fallback;
|
||||
- a shared entry is never returned older than the reader's own max_age,
|
||||
whoever wrote it and whatever ttl they stored (checked against a real
|
||||
CacheManager, whose stored ttl otherwise wins over the reader's);
|
||||
- two consumers of one scoreboard cost one request;
|
||||
- the counters see it.
|
||||
|
||||
No network: sessions are fakes, and the fetch service is a fresh one per test.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from src.common import espn_dates
|
||||
from src.common import fetch_service as fs
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
espn_scoreboard_cache_key,
|
||||
espn_scoreboard_cache_key_for_url,
|
||||
espn_scoreboard_url,
|
||||
get_espn_scoreboard,
|
||||
read_espn_scoreboard_cache,
|
||||
store_espn_scoreboard_cache,
|
||||
)
|
||||
from src.common.fetch_service import FetchService, plugin_scope
|
||||
from src.common.sports_fetch import SportsFetchMixin
|
||||
|
||||
NFL_URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||
|
||||
|
||||
# --- fakes ---------------------------------------------------------------------------
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def _response(body, url, max_age=None):
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
response._content = json.dumps(body).encode()
|
||||
response.headers = CaseInsensitiveDict(
|
||||
{"Cache-Control": f"max-age={max_age}"} if max_age else {})
|
||||
response.url = url
|
||||
response.encoding = "utf-8"
|
||||
return response
|
||||
|
||||
|
||||
class Espn(requests.Session):
|
||||
"""A Session answering every scoreboard GET with ``{"events": [<dates>]}``."""
|
||||
|
||||
def __init__(self, max_age=None, fail=None):
|
||||
super().__init__()
|
||||
self.calls = []
|
||||
self.max_age = max_age
|
||||
self.fail = fail
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
with self._lock:
|
||||
self.calls.append((url, dict(kwargs.get("params") or {})))
|
||||
if self.fail:
|
||||
raise self.fail
|
||||
dates = (kwargs.get("params") or {}).get("dates", "current")
|
||||
return _response({"events": [{"id": dates}]}, url, self.max_age)
|
||||
|
||||
|
||||
class RecordCache:
|
||||
"""The CacheManager surface these helpers use, records and all."""
|
||||
|
||||
def __init__(self):
|
||||
self.records = {}
|
||||
self.deleted = []
|
||||
|
||||
def set(self, key, data, ttl=None):
|
||||
record = {"timestamp": time.time()}
|
||||
if ttl is not None:
|
||||
record["ttl"] = ttl
|
||||
record["data"] = data
|
||||
self.records[key] = record
|
||||
|
||||
def put(self, key, data, age, ttl=None):
|
||||
self.set(key, data, ttl)
|
||||
self.records[key]["timestamp"] -= age
|
||||
|
||||
def get_cached_data(self, key, max_age=300, memory_ttl=None):
|
||||
return self.records.get(key)
|
||||
|
||||
def get(self, key, max_age=300, memory_ttl=None):
|
||||
record = self.records.get(key)
|
||||
return record["data"] if record else None
|
||||
|
||||
def delete(self, key):
|
||||
self.deleted.append(key)
|
||||
self.records.pop(key, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(monkeypatch):
|
||||
svc = FetchService({"rate_limits": {}})
|
||||
monkeypatch.setattr(fs, "_service", svc)
|
||||
return svc
|
||||
|
||||
|
||||
def _totals(svc):
|
||||
return svc.snapshot()["totals"]
|
||||
|
||||
|
||||
# --- the key -------------------------------------------------------------------------
|
||||
|
||||
class TestCanonicalKey:
|
||||
|
||||
def test_one_spelling_for_one_scoreboard(self):
|
||||
key = "espn_scoreboard_football_nfl_20261004"
|
||||
assert espn_scoreboard_cache_key("football", "nfl", "20261004") == key
|
||||
assert espn_scoreboard_cache_key(" Football ", "NFL", "20261004") == key
|
||||
assert espn_scoreboard_cache_key("football", "nfl", date(2026, 10, 4)) == key
|
||||
assert espn_scoreboard_cache_key("football", "nfl", datetime(2026, 10, 4, 23, 59)) == key
|
||||
assert espn_scoreboard_cache_key("football", "nfl", ("20261004", date(2026, 10, 4))) == key
|
||||
assert espn_scoreboard_cache_key_for_url(NFL_URL, "20261004") == key
|
||||
assert espn_scoreboard_cache_key_for_url(NFL_URL + "?limit=500", "20261004") == key
|
||||
|
||||
@pytest.mark.parametrize("dates,suffix", [
|
||||
(None, "current"),
|
||||
("", "current"),
|
||||
("2026", "2026"),
|
||||
("202610", "202610"),
|
||||
("20260925-20261016", "20260925-20261016"),
|
||||
((date(2026, 9, 25), date(2026, 10, 16)), "20260925-20261016"),
|
||||
])
|
||||
def test_every_dates_form_espn_takes(self, dates, suffix):
|
||||
assert espn_scoreboard_cache_key("soccer", "eng.1", dates) == f"espn_scoreboard_soccer_eng.1_{suffix}"
|
||||
|
||||
@pytest.mark.parametrize("sport,league,dates", [
|
||||
("football", "nfl", "2026-10-04"),
|
||||
("football", "nfl", "tomorrow"),
|
||||
("football", "nfl", ("20261004",)),
|
||||
("football", "nfl", True),
|
||||
("football", "", "20261004"),
|
||||
("foot ball", "nfl", "20261004"),
|
||||
("football", "nfl/../x", "20261004"),
|
||||
])
|
||||
def test_anything_else_is_refused_not_guessed(self, sport, league, dates):
|
||||
with pytest.raises(ValueError):
|
||||
espn_scoreboard_cache_key(sport, league, dates)
|
||||
|
||||
def test_a_url_that_is_not_a_scoreboard_has_no_key(self):
|
||||
assert espn_scoreboard_cache_key_for_url(
|
||||
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams") is None
|
||||
|
||||
def test_the_url_it_names(self):
|
||||
assert espn_scoreboard_url("Football", "college-football") == (
|
||||
"https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard")
|
||||
|
||||
|
||||
class Host(SportsFetchMixin):
|
||||
def __init__(self, session, cache):
|
||||
self.session = session
|
||||
self.headers = {"User-Agent": "test"}
|
||||
self.cache_manager = cache
|
||||
self.logger = logging.getLogger("test.espn_cache")
|
||||
self._games_lock = threading.RLock()
|
||||
self.sport = "football"
|
||||
self.league = "nfl"
|
||||
|
||||
|
||||
class TestEveryCallerUsesTheKey:
|
||||
"""The core helpers that cache a scoreboard all write the same key."""
|
||||
|
||||
DAY = "20261004"
|
||||
KEY = "espn_scoreboard_football_nfl_20261004"
|
||||
|
||||
def test_get_espn_scoreboard(self, service):
|
||||
cache = RecordCache()
|
||||
get_espn_scoreboard(Espn(), "football", "nfl", self.DAY, cache_manager=cache)
|
||||
assert list(cache.records) == [self.KEY]
|
||||
|
||||
def test_api_helper(self, service):
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
cache = RecordCache()
|
||||
helper = APIHelper(cache_manager=cache)
|
||||
helper.set_rate_limit(0)
|
||||
helper.session = Espn()
|
||||
helper.fetch_espn_scoreboard("football", "nfl", date=self.DAY)
|
||||
assert list(cache.records) == [self.KEY]
|
||||
|
||||
def test_the_scoreboard_mixin(self, service):
|
||||
cache = RecordCache()
|
||||
host = Host(Espn(), cache)
|
||||
assert host._schedule_cache_key(self.DAY) == self.KEY
|
||||
host._fetch_season_directly(NFL_URL, self.DAY, None, "today")
|
||||
assert list(cache.records) == [self.KEY]
|
||||
|
||||
def test_the_mixin_without_a_scoreboard_url(self, service):
|
||||
cache = RecordCache()
|
||||
Host(Espn(), cache)._fetch_season_directly(
|
||||
"https://example.test/feed", self.DAY, None, "today")
|
||||
assert list(cache.records) == [self.KEY]
|
||||
|
||||
def test_an_explicit_key_is_still_the_callers(self, service):
|
||||
cache = RecordCache()
|
||||
Host(Espn(), cache)._fetch_season_directly(NFL_URL, self.DAY, "mine", "today")
|
||||
assert list(cache.records) == ["mine"]
|
||||
|
||||
|
||||
# --- reading ---------------------------------------------------------------------------
|
||||
|
||||
class TestRead:
|
||||
|
||||
KEY = "espn_scoreboard_football_nfl_20261004"
|
||||
OLD = "scoreboard_data_football_nfl_20261004"
|
||||
|
||||
def test_a_fresh_canonical_entry(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.KEY, {"events": ["new"]}, age=10)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30) == {"events": ["new"]}
|
||||
assert (_totals(service)["cache_hits"], _totals(service)["legacy_cache_hits"]) == (1, 0)
|
||||
|
||||
def test_the_old_key_is_read_after_an_upgrade(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.OLD, {"events": ["old"]}, age=10)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30, [self.OLD]) == {"events": ["old"]}
|
||||
assert (_totals(service)["cache_hits"], _totals(service)["legacy_cache_hits"]) == (1, 1)
|
||||
|
||||
def test_the_canonical_key_wins_over_an_old_one(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.OLD, {"events": ["old"]}, age=1)
|
||||
cache.put(self.KEY, {"events": ["new"]}, age=20)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30, [self.OLD]) == {"events": ["new"]}
|
||||
|
||||
def test_a_stale_old_key_is_a_miss_too(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.OLD, {"events": ["old"]}, age=31)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30, [self.OLD]) is None
|
||||
assert _totals(service)["cache_hits"] == 0
|
||||
|
||||
def test_age_is_judged_against_an_injected_clock(self, service):
|
||||
cache = RecordCache()
|
||||
cache.records[self.KEY] = {"timestamp": 5000.0, "data": {"events": []}}
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30, now=5030.0) == {"events": []}
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30, now=5030.5) is None
|
||||
|
||||
def test_never_older_than_the_readers_ttl_whatever_the_writer_stored(self, service):
|
||||
# A writer that stored ttl=3600 must not make a 30 s reader take an
|
||||
# entry a minute old (CacheManager itself would let the ttl win).
|
||||
cache = RecordCache()
|
||||
cache.put(self.KEY, {"events": []}, age=60, ttl=3600)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 30) is None
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 90) == {"events": []}
|
||||
|
||||
def test_accept_can_turn_down_an_entry_and_sees_its_age(self, service):
|
||||
cache = RecordCache()
|
||||
cache.records[self.KEY] = {"timestamp": 5000.0, "data": {"events": ["live"]}}
|
||||
seen = []
|
||||
|
||||
def live_needs_30s(data, age):
|
||||
seen.append(age)
|
||||
return age <= 30
|
||||
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 300, now=5040.0,
|
||||
accept=live_needs_30s) is None
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, 300, now=5020.0,
|
||||
accept=live_needs_30s) == {"events": ["live"]}
|
||||
assert seen == [40.0, 20.0]
|
||||
assert _totals(service)["cache_hits"] == 1 # the turned-down read is no hit
|
||||
|
||||
@pytest.mark.parametrize("max_age", [0, -5])
|
||||
def test_no_max_age_no_read(self, service, max_age):
|
||||
cache = RecordCache()
|
||||
cache.put(self.KEY, {"events": []}, age=0)
|
||||
assert read_espn_scoreboard_cache(cache, self.KEY, max_age) is None
|
||||
|
||||
def test_a_broken_cache_is_a_miss(self, service):
|
||||
class Broken:
|
||||
def get_cached_data(self, *a, **k):
|
||||
raise OSError("disk gone")
|
||||
assert read_espn_scoreboard_cache(Broken(), self.KEY, 30) is None
|
||||
assert read_espn_scoreboard_cache(None, self.KEY, 30) is None
|
||||
|
||||
def test_a_cache_without_records(self, service):
|
||||
class Plain:
|
||||
def get(self, key, max_age=None):
|
||||
return {"events": [key, max_age]}
|
||||
assert read_espn_scoreboard_cache(Plain(), self.KEY, 29.5) == {"events": [self.KEY, 30]}
|
||||
|
||||
|
||||
class TestWithARealCacheManager:
|
||||
"""The same promise against CacheManager, memory and disk tiers."""
|
||||
|
||||
KEY = "espn_scoreboard_football_nfl_20261004"
|
||||
|
||||
@pytest.fixture
|
||||
def cm(self, tmp_path):
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
cm = CacheManager()
|
||||
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
|
||||
cm._memory_cache_component = MemoryCache()
|
||||
return cm
|
||||
|
||||
def _age_on_disk(self, cm, seconds):
|
||||
path = cm._disk_cache_component.get_cache_path(self.KEY)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
record = json.load(fh)
|
||||
record["timestamp"] = time.time() - seconds
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh)
|
||||
cm._memory_cache_component.clear()
|
||||
|
||||
def test_a_writers_long_ttl_does_not_outlast_the_readers(self, cm, service):
|
||||
cm.set(self.KEY, {"events": [1]}, ttl=3600)
|
||||
self._age_on_disk(cm, 60)
|
||||
assert cm.get(self.KEY, max_age=30) == {"events": [1]} # what a plain read does
|
||||
assert read_espn_scoreboard_cache(cm, self.KEY, 30) is None
|
||||
assert read_espn_scoreboard_cache(cm, self.KEY, 120) == {"events": [1]}
|
||||
|
||||
def test_a_copy_loaded_into_memory_keeps_its_real_age(self, cm, service):
|
||||
store_espn_scoreboard_cache(cm, self.KEY, {"events": [1]})
|
||||
self._age_on_disk(cm, 60)
|
||||
assert read_espn_scoreboard_cache(cm, self.KEY, 120) == {"events": [1]} # now in memory
|
||||
assert read_espn_scoreboard_cache(cm, self.KEY, 30) is None
|
||||
|
||||
def test_the_canonical_entry_stores_no_ttl(self, cm, service):
|
||||
store_espn_scoreboard_cache(cm, self.KEY, {"events": []})
|
||||
path = cm._disk_cache_component.get_cache_path(self.KEY)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
assert "ttl" not in json.load(fh)
|
||||
|
||||
|
||||
# --- fetching through the cache ----------------------------------------------------------------
|
||||
|
||||
class TestGetEspnScoreboard:
|
||||
|
||||
def test_a_miss_fetches_a_whole_page_and_caches_it(self, service):
|
||||
cache, espn = RecordCache(), Espn()
|
||||
data = get_espn_scoreboard(espn, "football", "nfl", "20261004", cache_manager=cache)
|
||||
assert data == {"events": [{"id": "20261004"}]}
|
||||
assert espn.calls == [(NFL_URL, {"dates": "20261004", "limit": ESPN_MAX_LIMIT})]
|
||||
assert "ttl" not in cache.records["espn_scoreboard_football_nfl_20261004"]
|
||||
|
||||
def test_a_hit_sends_nothing(self, service):
|
||||
cache, espn = RecordCache(), Espn()
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", cache_manager=cache)
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", cache_manager=cache, max_age=60)
|
||||
assert len(espn.calls) == 1
|
||||
assert _totals(service)["cache_hits"] == 1
|
||||
|
||||
def test_max_age_zero_always_fetches_and_still_caches(self, service):
|
||||
cache, espn = RecordCache(), Espn()
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", cache_manager=cache, max_age=0)
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", cache_manager=cache, max_age=0)
|
||||
assert len(espn.calls) == 2
|
||||
assert "espn_scoreboard_football_nfl_20261004" in cache.records
|
||||
|
||||
def test_the_current_scoreboard_sends_no_dates(self, service):
|
||||
espn = Espn()
|
||||
get_espn_scoreboard(espn, "football", "nfl", cache_manager=RecordCache())
|
||||
assert espn.calls == [(NFL_URL, {"limit": ESPN_MAX_LIMIT})]
|
||||
|
||||
def test_a_range_is_fetched_in_chunks_once_espn_rejects_it(self, service, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() + 60)
|
||||
cache, espn = RecordCache(), Espn()
|
||||
data = get_espn_scoreboard(espn, "football", "nfl", "20261003-20261004", cache_manager=cache)
|
||||
assert [e["id"] for e in data["events"]] == ["20261003", "20261004"]
|
||||
assert list(cache.records) == ["espn_scoreboard_football_nfl_20261003-20261004"]
|
||||
|
||||
def test_a_failure_raises_and_caches_nothing(self, service):
|
||||
cache = RecordCache()
|
||||
with pytest.raises(requests.ConnectionError):
|
||||
get_espn_scoreboard(Espn(fail=requests.ConnectionError("down")), "football", "nfl",
|
||||
"20261004", cache_manager=cache)
|
||||
assert cache.records == {}
|
||||
|
||||
def test_no_cache_manager_just_fetches(self, service):
|
||||
espn = Espn()
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
||||
assert len(espn.calls) == 2
|
||||
|
||||
def test_the_response_cache_is_bounded_by_the_callers_ttl(self, monkeypatch):
|
||||
clock = Clock()
|
||||
svc = FetchService({"rate_limits": {}}, clock=clock.now)
|
||||
monkeypatch.setattr(fs, "_service", svc)
|
||||
espn = Espn(max_age=450)
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", max_age=0)
|
||||
clock.t += 20
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", max_age=10)
|
||||
assert len(espn.calls) == 2 # 20 s old > the caller's 10
|
||||
clock.t += 5
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004", max_age=10)
|
||||
assert len(espn.calls) == 2 # 5 s old, and ESPN said 450
|
||||
assert svc.snapshot()["totals"]["memo_hits"] == 1
|
||||
|
||||
|
||||
class TestTwoConsumersOneFetch:
|
||||
"""A scoreboard's live poll and odds-ticker asking for the same day."""
|
||||
|
||||
def test_the_second_consumer_is_served_from_the_first_ones_copy(self, service):
|
||||
cache, espn = RecordCache(), Espn()
|
||||
with plugin_scope("football-scoreboard"):
|
||||
live = get_espn_scoreboard(espn, "football", "nfl", "20261004",
|
||||
cache_manager=cache, max_age=0)
|
||||
with plugin_scope("odds-ticker"):
|
||||
ticker = get_espn_scoreboard(
|
||||
espn, "football", "nfl", "20261004", cache_manager=cache, max_age=300,
|
||||
legacy_keys=["scoreboard_data_football_nfl_20261004"])
|
||||
assert ticker == live
|
||||
assert len(espn.calls) == 1
|
||||
snap = service.snapshot()
|
||||
assert snap["plugins"]["odds-ticker"]["cache_hits"] == 1
|
||||
assert snap["plugins"]["odds-ticker"]["requests"] == 0
|
||||
assert snap["plugins"]["football-scoreboard"]["requests"] == 1
|
||||
|
||||
def test_concurrent_misses_go_to_espn_once(self, service):
|
||||
gate = threading.Event()
|
||||
|
||||
class Slow(Espn):
|
||||
def get(self, url, **kwargs):
|
||||
gate.wait(5)
|
||||
return super().get(url, **kwargs)
|
||||
|
||||
espn, results = Slow(), []
|
||||
threads = [threading.Thread(target=lambda: results.append(get_espn_scoreboard(
|
||||
espn, "football", "nfl", "20261004", cache_manager=RecordCache(), max_age=60)))
|
||||
for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
with service._lock:
|
||||
if any(f.waiters for f in service._inflight.values()):
|
||||
break
|
||||
time.sleep(0.005)
|
||||
gate.set()
|
||||
for thread in threads:
|
||||
thread.join(5)
|
||||
assert len(espn.calls) == 1 and results[0] == results[1]
|
||||
|
||||
|
||||
# --- the scoreboards' schedule window ----------------------------------------------------------
|
||||
|
||||
class TestCachedSchedule:
|
||||
|
||||
KEY = "espn_scoreboard_football_nfl_20260925-20261016"
|
||||
YESTERDAY = "espn_scoreboard_football_nfl_20260924-20261015"
|
||||
OLD = "nfl_schedule_window_7_14"
|
||||
|
||||
def test_the_canonical_copy(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.KEY, {"events": ["new"]}, age=0)
|
||||
assert Host(Espn(), cache)._cached_schedule(self.KEY, [self.OLD]) == {"events": ["new"]}
|
||||
assert cache.deleted == []
|
||||
|
||||
def test_the_old_key_after_an_upgrade_counted_as_legacy(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.OLD, {"events": ["old"]}, age=0)
|
||||
assert Host(Espn(), cache)._cached_schedule(self.KEY, [self.OLD]) == {"events": ["old"]}
|
||||
totals = _totals(service)
|
||||
assert (totals["legacy_cache_hits"], totals["cache_hits"]) == (1, 0)
|
||||
|
||||
def test_a_miss_retires_yesterdays_window(self, service):
|
||||
cache = RecordCache()
|
||||
cache.put(self.YESTERDAY, {"events": []}, age=0)
|
||||
assert Host(Espn(), cache)._cached_schedule(self.KEY) is None
|
||||
assert cache.deleted == [self.YESTERDAY]
|
||||
assert self.YESTERDAY not in cache.records
|
||||
|
||||
def test_a_key_that_is_not_a_window_retires_nothing(self, service):
|
||||
cache = RecordCache()
|
||||
Host(Espn(), cache)._cached_schedule("espn_scoreboard_football_nfl_20261004")
|
||||
Host(Espn(), cache)._cached_schedule(self.OLD)
|
||||
assert cache.deleted == []
|
||||
|
||||
def test_a_cache_without_delete(self, service):
|
||||
class NoDelete(RecordCache):
|
||||
delete = None
|
||||
assert Host(Espn(), NoDelete())._cached_schedule(self.KEY) is None
|
||||
@@ -876,3 +876,267 @@ def test_the_web_route_returns_the_published_counters(clock):
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["status"] == "live"
|
||||
assert body["data"]["data"]["plugins"]["weather"]["requests"] == 1
|
||||
|
||||
|
||||
# --- response cache (stage 2: Cache-Control max-age) -------------------------------------------
|
||||
|
||||
def fresh_for(seconds, body=b'{"ok": 1}', **headers):
|
||||
"""A handler answering 200 with ``Cache-Control: max-age=<seconds>``."""
|
||||
def handler(url, kwargs):
|
||||
return make_response(200, body, url=url, headers={
|
||||
"Cache-Control": f"max-age={seconds}", **headers})
|
||||
return handler
|
||||
|
||||
|
||||
class NeverHits:
|
||||
"""A cache that always misses, so APIHelper always reaches the network."""
|
||||
|
||||
def get(self, key, max_age=None):
|
||||
return None
|
||||
|
||||
def set(self, key, value, ttl=None):
|
||||
pass
|
||||
|
||||
|
||||
class TestResponseCache:
|
||||
|
||||
def test_an_identical_get_inside_max_age_is_not_sent(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
first = service.get(session, "https://api.test/x", params={"d": 1}, timeout=5)
|
||||
second = service.get(session, "https://api.test/x", params={"d": 1}, timeout=5)
|
||||
assert len(session.calls) == 1
|
||||
assert second.json() == first.json() == {"ok": 1}
|
||||
assert second is not first
|
||||
totals = _counters(service)
|
||||
assert (totals["requests"], totals["memo_hits"]) == (1, 1)
|
||||
|
||||
def test_a_hit_is_a_copy_the_caller_may_change(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
service.get(session, "https://api.test/x").headers["X-Mine"] = "1"
|
||||
assert "X-Mine" not in service.get(session, "https://api.test/x").headers
|
||||
|
||||
def test_past_max_age_the_network_is_asked_again(self, service, clock):
|
||||
session = FakeSession(fresh_for(10))
|
||||
service.get(session, "https://api.test/x")
|
||||
clock.advance(9.9)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 1
|
||||
clock.advance(0.2)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_the_age_header_shortens_the_lifetime(self, service, clock):
|
||||
session = FakeSession(fresh_for(10, Age="8"))
|
||||
service.get(session, "https://api.test/x")
|
||||
clock.advance(2.5)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_never_older_than_the_callers_own_ttl(self, service, clock):
|
||||
session = FakeSession(fresh_for(400))
|
||||
service.get(session, "https://api.test/x", cache_max_age=0)
|
||||
clock.advance(20)
|
||||
service.get(session, "https://api.test/x", cache_max_age=10) # 20 s > 10
|
||||
assert len(session.calls) == 2
|
||||
clock.advance(20)
|
||||
service.get(session, "https://api.test/x", cache_max_age=30) # 20 s <= 30
|
||||
assert len(session.calls) == 2
|
||||
service.get(session, "https://api.test/x", cache_max_age=19.9) # 20 s > 19.9
|
||||
assert len(session.calls) == 3
|
||||
|
||||
def test_a_caller_that_does_not_say_gets_the_default_limit(self, service, clock):
|
||||
session = FakeSession(fresh_for(450))
|
||||
service.get(session, "https://api.test/x")
|
||||
clock.advance(service.default_max_age - 0.5)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 1
|
||||
clock.advance(1)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
# ...while a caller with a longer TTL still takes the server at its word.
|
||||
clock.advance(100)
|
||||
service.get(session, "https://api.test/x", cache_max_age=300)
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_zero_always_asks_but_still_fills_the_cache(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
service.get(session, "https://api.test/x", cache_max_age=0)
|
||||
service.get(session, "https://api.test/x", cache_max_age=0)
|
||||
assert len(session.calls) == 2
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_cache_max_age_never_reaches_the_session(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
service.get(session, "https://api.test/x", timeout=5, cache_max_age=30)
|
||||
assert session.calls[0][1] == {"timeout": 5}
|
||||
|
||||
@pytest.mark.parametrize("status,headers", [
|
||||
(200, {"Cache-Control": "no-store, max-age=60"}),
|
||||
(200, {"Cache-Control": "no-cache, max-age=60"}),
|
||||
(200, {"Cache-Control": "private, max-age=60"}),
|
||||
(200, {"Cache-Control": "max-age=60", "Vary": "*"}),
|
||||
(200, {"Cache-Control": "max-age=60", "Set-Cookie": "sid=1"}),
|
||||
(200, {"Cache-Control": "max-age=0"}),
|
||||
(200, {"Cache-Control": "max-age=soon"}),
|
||||
(200, {"Cache-Control": "max-age=60", "Age": "60"}),
|
||||
(200, {}),
|
||||
(404, {"Cache-Control": "max-age=60"}),
|
||||
(500, {"Cache-Control": "max-age=60"}),
|
||||
])
|
||||
def test_responses_that_must_not_be_reused_are_not(self, service, status, headers):
|
||||
session = FakeSession(lambda url, kw: make_response(status, b"{}", headers=headers, url=url))
|
||||
service.get(session, "https://api.test/x")
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
assert service.snapshot()["response_cache"]["entries"] == 0
|
||||
|
||||
def test_a_stream_is_never_kept(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
service.get(session, "https://api.test/x", stream=True)
|
||||
service.get(session, "https://api.test/x", stream=True)
|
||||
assert len(session.calls) == 2
|
||||
|
||||
@pytest.mark.parametrize("second", [
|
||||
{"params": {"d": 2}},
|
||||
{"headers": {"Accept": "text/html"}},
|
||||
{"headers": {"If-None-Match": '"x"'}}, # the caller's own revalidation
|
||||
])
|
||||
def test_requests_that_could_answer_differently_do_not_share(self, service, second):
|
||||
session = FakeSession(fresh_for(60))
|
||||
service.get(session, "https://api.test/x", params={"d": 1})
|
||||
service.get(session, "https://api.test/x", **{"params": {"d": 1}, **second})
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_another_timeout_or_retry_policy_still_shares(self, service):
|
||||
# A finished 200 is the same answer however long the caller would have
|
||||
# waited or however often retried; only in-flight merging needs those.
|
||||
plain, retrying = FakeSession(fresh_for(60)), FakeSession(fresh_for(60))
|
||||
retrying.mount("https://", requests.adapters.HTTPAdapter(max_retries=Retry(total=5)))
|
||||
service.get(plain, "https://api.test/x", timeout=5)
|
||||
service.get(retrying, "https://api.test/x", timeout=30)
|
||||
assert len(plain.calls) == 1 and retrying.calls == []
|
||||
|
||||
def test_a_session_with_cookies_only_reuses_its_own(self, service):
|
||||
cookied, plain = FakeSession(fresh_for(60)), FakeSession(fresh_for(60))
|
||||
cookied.cookies.set("sid", "secret")
|
||||
service.get(cookied, "https://api.test/x")
|
||||
service.get(plain, "https://api.test/x")
|
||||
service.get(cookied, "https://api.test/x")
|
||||
assert len(cookied.calls) == len(plain.calls) == 1
|
||||
|
||||
def test_size_bounds(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "response_cache": {
|
||||
"max_entry_bytes": 10, "max_bytes": 25, "max_entries": 10}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
big = FakeSession(fresh_for(60, body=b"x" * 11))
|
||||
svc.get(big, "https://api.test/big")
|
||||
assert svc.snapshot()["response_cache"]["entries"] == 0
|
||||
small = FakeSession(fresh_for(60, body=b"y" * 10))
|
||||
for name in "abc":
|
||||
svc.get(small, f"https://api.test/{name}")
|
||||
assert svc.snapshot()["response_cache"] == {"entries": 2, "bytes": 20}
|
||||
svc.get(small, "https://api.test/a") # evicted, least recently used
|
||||
assert len(small.calls) == 4
|
||||
|
||||
def test_expired_entries_are_dropped_on_insert(self, service, clock):
|
||||
session = FakeSession(fresh_for(5))
|
||||
service.get(session, "https://api.test/a")
|
||||
clock.advance(6)
|
||||
service.get(session, "https://api.test/b")
|
||||
assert service.snapshot()["response_cache"]["entries"] == 1
|
||||
|
||||
def test_off_switch(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "response_cache": {"enabled": False}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession(fresh_for(60))
|
||||
svc.get(session, "https://api.test/x")
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
assert svc.describe_config()["response_cache"] is False
|
||||
|
||||
def test_merged_callers_and_the_cache_together(self, service):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(fresh_for(60), gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(session, "https://api.test/x"))
|
||||
a.start()
|
||||
assert session.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(session, "https://api.test/x"))
|
||||
b.start()
|
||||
_wait_for_waiters(service, 1)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 1
|
||||
totals = _counters(service)
|
||||
assert (totals["requests"], totals["merged"], totals["memo_hits"]) == (1, 1, 1)
|
||||
|
||||
def test_hits_are_counted_per_plugin_and_host(self, service):
|
||||
session = FakeSession(fresh_for(60))
|
||||
with plugin_scope("odds-ticker"):
|
||||
service.get(session, "https://site.api.espn.com/x")
|
||||
with plugin_scope("football-scoreboard"):
|
||||
service.get(session, "https://site.api.espn.com/x")
|
||||
snap = service.snapshot()
|
||||
assert snap["plugins"]["football-scoreboard"]["memo_hits"] == 1
|
||||
assert snap["plugins"]["football-scoreboard"]["requests"] == 0
|
||||
assert snap["plugins"]["football-scoreboard"]["hosts"] == {"site.api.espn.com": 1}
|
||||
assert snap["hosts"]["site.api.espn.com"]["memo_hits"] == 1
|
||||
|
||||
def test_the_odds_manager_never_takes_odds_older_than_its_interval(self, global_service, clock):
|
||||
from unittest.mock import MagicMock
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get_with_auto_strategy.return_value = None
|
||||
manager = BaseOddsManager(cache)
|
||||
manager.session = FakeSession(fresh_for(450, body=b'{"count": 0, "items": []}'))
|
||||
manager.get_odds("football", "nfl", "401", update_interval_seconds=60)
|
||||
clock.advance(59)
|
||||
manager.get_odds("football", "nfl", "401", update_interval_seconds=60)
|
||||
assert len(manager.session.calls) == 1
|
||||
clock.advance(2)
|
||||
manager.get_odds("football", "nfl", "401", update_interval_seconds=60)
|
||||
assert len(manager.session.calls) == 2
|
||||
|
||||
def test_the_api_helper_bounds_a_cached_get_by_its_ttl(self, global_service, clock):
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
helper = APIHelper()
|
||||
helper.set_rate_limit(0)
|
||||
helper.session = FakeSession(fresh_for(450, body=b'{"a": 1}'))
|
||||
helper.get("https://api.test/x")
|
||||
clock.advance(31)
|
||||
helper.get("https://api.test/x") # no TTL: the 30 s default
|
||||
assert len(helper.session.calls) == 2
|
||||
clock.advance(31)
|
||||
helper.cache_manager = NeverHits()
|
||||
helper.get("https://api.test/x", cache_key="k", cache_ttl=60) # 31 s <= 60
|
||||
assert len(helper.session.calls) == 2
|
||||
|
||||
|
||||
class TestCacheHitCounters:
|
||||
|
||||
def test_a_shared_cache_hit(self, service):
|
||||
with plugin_scope("odds-ticker"):
|
||||
service.note_cache_hit("https://site.api.espn.com/")
|
||||
counters = _counters(service, plugin="odds-ticker")
|
||||
assert (counters["cache_hits"], counters["legacy_cache_hits"]) == (1, 0)
|
||||
assert counters["hosts"] == {"site.api.espn.com": 1}
|
||||
|
||||
def test_a_legacy_hit(self, service):
|
||||
service.note_cache_hit("https://site.api.espn.com/", legacy=True)
|
||||
totals = _counters(service)
|
||||
assert (totals["cache_hits"], totals["legacy_cache_hits"]) == (1, 1)
|
||||
|
||||
def test_a_read_that_avoided_no_request(self, service):
|
||||
service.note_cache_hit("https://site.api.espn.com/", legacy=True, avoided_request=False)
|
||||
totals = _counters(service)
|
||||
assert (totals["cache_hits"], totals["legacy_cache_hits"]) == (0, 1)
|
||||
|
||||
def test_every_counter_is_in_every_snapshot(self, service):
|
||||
snap = service.snapshot()
|
||||
for name in ("memo_hits", "cache_hits", "legacy_cache_hits"):
|
||||
assert snap["totals"][name] == 0
|
||||
assert snap["response_cache"] == {"entries": 0, "bytes": 0}
|
||||
|
||||
Reference in New Issue
Block a user