fix: retry a bad data download, keep the site step

The buildbot answered once with something that was not ScummVM.zip. refresh_data_dirs gave up at once, Generate the site was skipped, and 228 site tests failed in its place. A failed or unreadable download is now tried three times, with waits of 5 and 20 s, and an empty archive still fails at once. The site step runs after a failed fetch, which stays red on its own step.
This commit is contained in:
Abdessamad Derraz committed 2026-10-28 12:33:23 +01:00
1 parent 647952b210
commit 9dd770f4bf
4 files changed
+79 -19

No files matched your search

+4 -1
View File
@@ -276,8 +276,11 @@ jobs:
# The pages are gitignored, and the tests that read them fail # The pages are gitignored, and the tests that read them fail
# in CI without it. The suite runs whatever this step returned, so a # in CI without it. The suite runs whatever this step returned, so a
# generator that stops still leaves every other test reporting. # generator that stops still leaves every other test reporting. It runs
# after a failed data fetch too: one bad mirror stays red on its own
# step instead of skipping the site and failing 228 site tests with it.
- name: Generate the site - name: Generate the site
if: ${{ !cancelled() }}
run: python scripts/generate_site.py run: python scripts/generate_site.py
- name: Run test suite - name: Run test suite
+27 -18
View File
@@ -20,6 +20,7 @@ import os
import shutil import shutil
import tarfile import tarfile
import tempfile import tempfile
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import zipfile import zipfile
@@ -40,6 +41,11 @@ VERSIONS_FILE = "data/.versions.json"
USER_AGENT = "retrobios/1.0" USER_AGENT = "retrobios/1.0"
REQUEST_TIMEOUT = 30 REQUEST_TIMEOUT = 30
DOWNLOAD_TIMEOUT = 300 DOWNLOAD_TIMEOUT = 300
# A mirror sometimes answers with a page that is not the archive: the
# buildbot's ScummVM.zip failed CI once as "File is not a zip file" and
# downloaded fine minutes later. Seconds to wait before each retry.
DOWNLOAD_RETRY_WAITS = (5, 20)
_TRANSIENT = (urllib.error.URLError, OSError, tarfile.TarError, zipfile.BadZipFile, EOFError)
def load_registry(registry_path: str = DEFAULT_REGISTRY) -> dict[str, dict]: def load_registry(registry_path: str = DEFAULT_REGISTRY) -> dict[str, dict]:
@@ -492,26 +498,29 @@ def _refresh_entry(
else: else:
remote_tag = get_remote_sha(entry["source_url"], version) remote_tag = get_remote_sha(entry["source_url"], version)
try: def fetch() -> int:
if source_type == "zip": if source_type == "zip":
strip = entry.get("strip_components", 0) return _download_and_extract_zip(
file_count = _download_and_extract_zip( source_url, local_cache, exclude, entry.get("strip_components", 0)
source_url, local_cache, exclude, strip
) )
else: source_path = entry["source_path"].format(version=version)
source_path = entry["source_path"].format(version=version) return _download_and_extract(source_url, source_path, local_cache, exclude)
file_count = _download_and_extract(
source_url, source_path, local_cache, exclude # An archive that reads but holds nothing under the path is the same on
) # every try: only a failed or unreadable download is tried again.
except ( for attempt, wait in enumerate((*DOWNLOAD_RETRY_WAITS, None), start=1):
urllib.error.URLError, try:
OSError, file_count = fetch()
tarfile.TarError, break
zipfile.BadZipFile, except NothingExtractedError as exc:
NothingExtractedError, log.warning("[%s] download failed: %s", key, exc)
) as exc: return None
log.warning("[%s] download failed: %s", key, exc) except _TRANSIENT as exc:
return None if wait is None:
log.warning("[%s] download failed after %d tries: %s", key, attempt, exc)
return None
log.warning("[%s] download failed (%s), trying again in %d s", key, exc, wait)
time.sleep(wait)
_record_version(key, {"sha": remote_tag or "", "version": version}, versions_path) _record_version(key, {"sha": remote_tag or "", "version": version}, versions_path)
+45
View File
@@ -15,6 +15,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
import zipfile
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -215,6 +216,50 @@ class TheVersionIsReadBeforeTheDownload(unittest.TestCase):
self.assertEqual(json.loads(versions.read_text())["sys"]["sha"], "build-1") self.assertEqual(json.loads(versions.read_text())["sys"]["sha"], "build-1")
class ABadResponseIsTriedAgain(unittest.TestCase):
"""The buildbot once served something that was not ScummVM.zip. CI
failed the whole data step on it, and the next download was fine."""
def setUp(self) -> None:
import refresh_data_dirs as rdd
self.rdd = rdd
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
self.versions = str(root / "versions.json")
self.entry = {"source_type": "zip", "source_url": "https://example.invalid/S.zip",
"local_cache": str(root / "cache")}
self.waits: list[int] = []
def run_with(self, outcomes: list) -> bool | None:
calls = iter(outcomes)
def download(*_args, **_kwargs):
outcome = next(calls)
if isinstance(outcome, Exception):
raise outcome
return outcome
with mock.patch.object(self.rdd, "_get_remote_etag", lambda _url: "tag"), \
mock.patch.object(self.rdd, "_download_and_extract_zip", download), \
mock.patch.object(self.rdd.time, "sleep", self.waits.append):
return self.rdd._refresh_entry("s", self.entry, True, False, self.versions)
def test_a_bad_archive_then_a_good_one_refreshes(self):
self.assertTrue(self.run_with([zipfile.BadZipFile("File is not a zip file"), 12]))
self.assertEqual(self.waits, [self.rdd.DOWNLOAD_RETRY_WAITS[0]])
def test_a_source_bad_on_every_try_fails_after_them(self):
bad = zipfile.BadZipFile("File is not a zip file")
tries = len(self.rdd.DOWNLOAD_RETRY_WAITS) + 1
self.assertIsNone(self.run_with([bad] * tries))
self.assertEqual(self.waits, list(self.rdd.DOWNLOAD_RETRY_WAITS))
def test_an_empty_archive_is_not_tried_again(self):
self.assertIsNone(self.run_with([self.rdd.NothingExtractedError("nothing")]))
self.assertEqual(self.waits, [])
class AKeyMustServeThePlatform(unittest.TestCase): class AKeyMustServeThePlatform(unittest.TestCase):
"""--platform narrowed only a full refresh: under --key it was ignored and """--platform narrowed only a full refresh: under --key it was ignored and
dolphin-sys was refreshed for RomM, which never reads it.""" dolphin-sys was refreshed for RomM, which never reads it."""
+3
View File
@@ -143,6 +143,9 @@ class ThePullRequestBuildsTheSite(unittest.TestCase):
suite = next(i for i, run in enumerate(runs) if "unittest discover" in run) suite = next(i for i, run in enumerate(runs) if "unittest discover" in run)
self.assertLess(generate, suite) self.assertLess(generate, suite)
self.assertEqual(steps[suite].get("if"), "${{ !cancelled() }}") self.assertEqual(steps[suite].get("if"), "${{ !cancelled() }}")
# A failed data fetch skipped the generator, and all 228 site tests
# then failed under it: the generator runs whatever came before.
self.assertEqual(steps[generate].get("if"), "${{ !cancelled() }}")
def test_every_site_input_triggers_validation(self): def test_every_site_input_triggers_validation(self):
deploy = self._workflow("deploy-site.yml") deploy = self._workflow("deploy-site.yml")