mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
fix(sports): share the ESPN rejected-range memo with the background service
BackgroundDataService always sent a season range first and, on a 400, fell back to chunks without recording the rejection, so every background season fetch spent a doomed request and live scoreboards learned nothing from it (or it from them). The worker now consults and sets the same 6-hour memo fetch_espn_scoreboard() uses: a known rejection goes straight to month/day chunks, and if every chunk fails the range is asked once for a real error without re-spending the chunks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,14 @@ from enum import Enum
|
|||||||
import queue
|
import queue
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
from src.common.espn_dates import clamp_espn_limit, fetch_espn_date_chunks
|
from src.common.espn_dates import (
|
||||||
|
RANGE_RETRY_SECONDS,
|
||||||
|
_note_range_rejected,
|
||||||
|
_ranges_known_rejected,
|
||||||
|
clamp_espn_limit,
|
||||||
|
fetch_espn_date_chunks,
|
||||||
|
parse_espn_date_range,
|
||||||
|
)
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -350,13 +357,32 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
||||||
|
|
||||||
# Perform HTTP request with retry logic
|
|
||||||
response = self._make_request_with_retry(request)
|
|
||||||
|
|
||||||
# ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and
|
# ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and
|
||||||
# answers 400 for every sport. Re-ask in months and days rather
|
# answers 400 for every sport. Re-ask in months and days rather
|
||||||
# than let a whole season fail. See src/common/espn_dates.py.
|
# than let a whole season fail. See src/common/espn_dates.py.
|
||||||
if response.status_code == 400:
|
# The "ranges are rejected" memo is shared with
|
||||||
|
# fetch_espn_scoreboard(): once either path has seen a range
|
||||||
|
# rejected, the other skips the doomed range request too.
|
||||||
|
is_range = parse_espn_date_range(request.params.get("dates")) is not None
|
||||||
|
data = None
|
||||||
|
chunks_tried = False
|
||||||
|
if is_range and _ranges_known_rejected():
|
||||||
|
data = self._fetch_in_date_chunks(request)
|
||||||
|
# Every chunk failed: ask for the range itself below so the
|
||||||
|
# failure carries a real HTTP error, without re-spending chunks.
|
||||||
|
chunks_tried = data is None
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
# Perform HTTP request with retry logic
|
||||||
|
response = self._make_request_with_retry(request)
|
||||||
|
if is_range and response.status_code == 400 and not chunks_tried:
|
||||||
|
_note_range_rejected()
|
||||||
|
logger.warning(
|
||||||
|
"ESPN rejected the date range %s (400); fetching it as "
|
||||||
|
"month/day chunks, and fetching ranges that way for the "
|
||||||
|
"next %d hours",
|
||||||
|
request.params.get("dates"), RANGE_RETRY_SECONDS // 3600,
|
||||||
|
)
|
||||||
data = self._fetch_in_date_chunks(request)
|
data = self._fetch_in_date_chunks(request)
|
||||||
if data is None:
|
if data is None:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.background_data_service import BackgroundDataService
|
from src.background_data_service import BackgroundDataService
|
||||||
from src.common.espn_dates import ESPN_MAX_LIMIT, parse_espn_date_range
|
from src.common import espn_dates
|
||||||
|
from src.common.espn_dates import (
|
||||||
|
ESPN_MAX_LIMIT,
|
||||||
|
fetch_espn_scoreboard,
|
||||||
|
parse_espn_date_range,
|
||||||
|
)
|
||||||
|
|
||||||
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||||
|
|
||||||
@@ -50,6 +55,12 @@ class RangeRejectingSession:
|
|||||||
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
|
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def ranges_not_yet_rejected(monkeypatch):
|
||||||
|
# The "ranges are rejected" memo is process-wide; every test starts clean.
|
||||||
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cache():
|
def cache():
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
@@ -161,3 +172,42 @@ def test_a_400_on_a_single_day_is_still_a_failure(service, cache):
|
|||||||
assert not result.success
|
assert not result.success
|
||||||
assert len(session.calls) == 1
|
assert len(session.calls) == 1
|
||||||
cache.set.assert_not_called()
|
cache.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service):
|
||||||
|
submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}),
|
||||||
|
"20260901-20260930")
|
||||||
|
|
||||||
|
# A live scoreboard asking for a range next must not spend a doomed 400.
|
||||||
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||||
|
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
|
||||||
|
assert [call["dates"] for call in session.calls] == ["202609"]
|
||||||
|
assert [event["id"] for event in data["events"]] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache):
|
||||||
|
fetch_espn_scoreboard(RangeRejectingSession(), URL,
|
||||||
|
params={"dates": "20260901-20260930"})
|
||||||
|
|
||||||
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||||
|
result = submit_and_wait(service, session, "20260901-20261001")
|
||||||
|
assert result.success, result.error
|
||||||
|
assert [call["dates"] for call in session.calls] == ["202609", "20261001"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache):
|
||||||
|
espn_dates._note_range_rejected()
|
||||||
|
session = RangeRejectingSession(fail_chunks={"202609"})
|
||||||
|
result = submit_and_wait(service, session, "20260901-20260930")
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
# Chunks once, then the range for a real error -- not the chunks again.
|
||||||
|
assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"]
|
||||||
|
cache.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch):
|
||||||
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1)
|
||||||
|
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||||
|
submit_and_wait(service, session, "20260901-20260930")
|
||||||
|
assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"]
|
||||||
|
|||||||
Reference in New Issue
Block a user