From 837dd1fde769b7f05a7f263b86457b43e351eaa3 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:27:40 -0400 Subject: [PATCH] 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 --- src/background_data_service.py | 44 ++++++++++++---- ...est_background_data_service_espn_ranges.py | 52 ++++++++++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/background_data_service.py b/src/background_data_service.py index 148232cb..de435147 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -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): diff --git a/test/test_background_data_service_espn_ranges.py b/test/test_background_data_service_espn_ranges.py index ab6edc5d..76acbfb3 100644 --- a/test/test_background_data_service_espn_ranges.py +++ b/test/test_background_data_service_espn_ranges.py @@ -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"]