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:
Chuck
2026-09-16 18:27:40 -04:00
co-authored by Claude Opus 5
parent 7ae614aa35
commit 837dd1fde7
2 changed files with 86 additions and 10 deletions
+35 -9
View File
@@ -25,7 +25,14 @@ from enum import Enum
import queue
from concurrent.futures import ThreadPoolExecutor
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
logger = logging.getLogger(__name__)
@@ -350,19 +357,38 @@ class BackgroundDataService:
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
# answers 400 for every sport. Re-ask in months and days rather
# 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)
if data is None:
# 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)
if data is None:
response.raise_for_status()
else:
response.raise_for_status()
else:
response.raise_for_status()
data = response.json()
data = response.json()
# Validate data structure
if not isinstance(data, dict):
@@ -13,7 +13,12 @@ from unittest.mock import MagicMock, patch
import pytest
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"
@@ -50,6 +55,12 @@ class RangeRejectingSession:
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
def cache():
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 len(session.calls) == 1
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"]