Skip to content

Commit c5ca4ce

Browse files
committed
feat(cot): Bug 2034520 validate downloads and auto-retry on parse failure
Combine download and parse into a single retry unit in load_json_or_yaml_from_url: if the downloaded file fails to parse as JSON/YAML, the cache is invalidated and retry_async triggers a fresh download (up to the default 5 attempts). This turns transient artifact- server glitches (e.g. GCS gunzip-transcoding returning 'Bad Request' with 200 OK, HTML error pages from hg, truncated responses) from hard failures into self-healing retries. download_file gains an optional expected_content_type kwarg that rejects 'text/html' responses when the caller asked for something else, failing fast with a clearer error before writing a byte. DownloadError is reused so retry_async picks it up if the HTML turns out to be transient. The misleading 'overwrite' parameter semantics are preserved (overwrite=True keeps any existing cached file; overwrite=False always redownloads) and flagged with a comment for future cleanup. Tests added for HTML rejection and the parse-failure-then-retry-succeeds flow.
1 parent 35f06b2 commit c5ca4ce

2 files changed

Lines changed: 97 additions & 19 deletions

File tree

src/scriptworker/utils.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -581,11 +581,7 @@ def load_json_or_yaml(
581581
except (OSError, ValueError, yaml.scanner.ScannerError) as exc:
582582
if exception is not None:
583583
if message is None:
584-
raise exception(
585-
f"Failed to load {file_type} from {string}: {exc}"
586-
if is_path
587-
else f"Failed to load {file_type}: {exc}"
588-
)
584+
raise exception(f"Failed to load {file_type} from {string}: {exc}" if is_path else f"Failed to load {file_type}: {exc}")
589585
repl_dict = {"exc": str(exc), "file_type": file_type, "path": string if is_path else ""}
590586
raise exception(message % repl_dict)
591587
return None
@@ -660,7 +656,7 @@ async def _log_download_error(resp, msg):
660656
log.debug("Redirect history %s: %s; body=%s", get_loggable_url(str(h.url)), h.status, (await h.text())[:1000])
661657

662658

663-
async def download_file(context, url, abs_filename, session=None, chunk_size=128, auth=None):
659+
async def download_file(context, url, abs_filename, session=None, chunk_size=128, auth=None, expected_content_type=None):
664660
"""Download a file, async.
665661
666662
Args:
@@ -671,6 +667,16 @@ async def download_file(context, url, abs_filename, session=None, chunk_size=128
671667
None, use context.session. Defaults to None.
672668
chunk_size (int, optional): the chunk size to read from the response
673669
at a time. Default is 128.
670+
expected_content_type (str, optional): if set, raise ``DownloadError``
671+
when the server returns an ``HTML`` response and ``expected_content_type``
672+
is something other than HTML. Narrow by design — servers vary too
673+
much for a strict match — but catches the common
674+
"error page instead of the JSON/YAML/artifact we asked for" case.
675+
676+
Raises:
677+
DownloadError: on non-200 status, or an HTML response when
678+
``expected_content_type`` was not HTML.
679+
Download404: on 404 status.
674680
675681
"""
676682
session = session or context.session
@@ -683,10 +689,15 @@ async def download_file(context, url, abs_filename, session=None, chunk_size=128
683689
async with session.get(url, auth=auth) as resp:
684690
if resp.status == 404:
685691
await _log_download_error(resp, "404 downloading %(url)s: %(status)s; body=%(body)s")
686-
raise Download404("{} status {}!".format(loggable_url, resp.status))
692+
raise Download404(f"{loggable_url} status {resp.status}!")
687693
elif resp.status != 200:
688694
await _log_download_error(resp, "Failed to download %(url)s: %(status)s; body=%(body)s")
689-
raise DownloadError("{} status {} is not 200!".format(loggable_url, resp.status))
695+
raise DownloadError(f"{loggable_url} status {resp.status} is not 200!")
696+
if expected_content_type:
697+
actual_content_type = (resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
698+
if actual_content_type == "text/html" and "html" not in expected_content_type.lower():
699+
await _log_download_error(resp, "HTML response for %(url)s (expected non-HTML): %(status)s; body=%(body)s")
700+
raise DownloadError(f"{loggable_url}: expected Content-Type {expected_content_type!r} but got HTML; treating as an error page")
690701
makedirs(parent_dir)
691702
with open(abs_filename, "wb") as fd:
692703
while True:
@@ -737,6 +748,11 @@ def get_parts_of_url_path(url):
737748
async def load_json_or_yaml_from_url(context: Context, url: str, path: str, overwrite: bool = True, auth: Optional[str] = None) -> Dict[str, Any]:
738749
"""Retry a json/yaml file download, load it, then return its data.
739750
751+
Download and parse are combined into a single retry unit: if parsing the
752+
downloaded file fails (e.g. truncated body, an HTML error page, a Cloud
753+
Storage transcoding glitch), the cached file is deleted and the download
754+
is retried.
755+
740756
Args:
741757
context (scriptworker.context.Context): the scriptworker context.
742758
url (str): the url to download
@@ -753,20 +769,41 @@ async def load_json_or_yaml_from_url(context: Context, url: str, path: str, over
753769
"""
754770
if path.endswith("json"):
755771
file_type = "json"
772+
expected_content_type = "application/json"
756773
else:
757774
file_type = "yaml"
775+
expected_content_type = "application/yaml"
758776

759-
kwargs = {}
777+
download_kwargs = {"expected_content_type": expected_content_type}
760778
if auth:
761-
kwargs = {"auth": auth}
762-
if not overwrite or not os.path.exists(path):
763-
await retry_async(download_file, args=(context, url, path), kwargs=kwargs, retry_exceptions=(DownloadError, aiohttp.ClientError, asyncio.TimeoutError))
779+
download_kwargs["auth"] = auth
764780
loggable_url = get_loggable_url(url)
765-
return load_json_or_yaml(
766-
path,
767-
is_path=True,
768-
file_type=file_type,
769-
message=f"Failed to load {file_type} from {loggable_url} (cached at {path}): %(exc)s",
781+
782+
async def _download_and_parse():
783+
# Pre-existing cache semantics (despite the misleading parameter
784+
# name): ``overwrite=True`` uses an existing file when present;
785+
# ``overwrite=False`` always (re)downloads.
786+
if not overwrite or not os.path.exists(path):
787+
await download_file(context, url, path, **download_kwargs)
788+
try:
789+
return load_json_or_yaml(path, is_path=True, file_type=file_type)
790+
except ScriptWorkerTaskException as exc:
791+
log.warning(
792+
"Failed to parse %s from %s (cached at %s); invalidating cache and retrying: %s",
793+
file_type,
794+
loggable_url,
795+
path,
796+
exc,
797+
)
798+
try:
799+
os.remove(path)
800+
except OSError:
801+
pass
802+
raise DownloadError(f"parse failure for {loggable_url}: {exc}")
803+
804+
return await retry_async(
805+
_download_and_parse,
806+
retry_exceptions=(DownloadError, aiohttp.ClientError, asyncio.TimeoutError),
770807
)
771808

772809

tests/test_utils.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,47 @@ async def test_download_file_404(rw_context, fake_session_404, tmpdir, auth):
443443
await utils.download_file(rw_context, "url", path, session=fake_session_404, auth=auth)
444444

445445

446+
@pytest.mark.asyncio
447+
async def test_download_file_rejects_html_when_non_html_expected(rw_context, fake_session, tmpdir):
448+
"""An HTML response raises DownloadError when a non-HTML content-type was expected."""
449+
450+
async def html_request(method, url, *args, **kwargs):
451+
resp = FakeResponse(method, url, status=200)
452+
resp._headers = {"Content-Type": "text/html; charset=utf-8"}
453+
return resp
454+
455+
fake_session._request = html_request
456+
path = os.path.join(tmpdir, "foo.json")
457+
with pytest.raises(DownloadError, match="HTML"):
458+
await utils.download_file(rw_context, "url", path, session=fake_session, expected_content_type="application/json")
459+
assert not os.path.exists(path)
460+
461+
462+
@pytest.mark.asyncio
463+
async def test_load_json_or_yaml_from_url_retries_on_parse_failure(rw_context, mocker, tmpdir):
464+
"""A parse failure invalidates the cache and triggers a re-download."""
465+
path = os.path.join(tmpdir, "out.json")
466+
call_count = {"n": 0}
467+
468+
async def flaky_download(rw_context, url, abs_filename, session=None, chunk_size=128, auth=None, expected_content_type=None):
469+
call_count["n"] += 1
470+
if call_count["n"] == 1:
471+
# First attempt: write garbage JSON
472+
with open(abs_filename, "w") as fh:
473+
fh.write("not valid json {")
474+
else:
475+
with open(abs_filename, "w") as fh:
476+
fh.write('{"ok": true}')
477+
478+
# Neutralize retry_async backoff so the test is fast
479+
mocker.patch.object(utils, "calculate_sleep_time", return_value=0)
480+
mocker.patch.object(utils, "download_file", new=flaky_download)
481+
482+
result = await utils.load_json_or_yaml_from_url(rw_context, "url", path)
483+
assert result == {"ok": True}
484+
assert call_count["n"] == 2
485+
486+
446487
# format_json {{{1
447488
def test_format_json():
448489
expected = "\n".join(["{", ' "a": 1,', ' "b": [', " 4,", " 3,", " 2", " ],", ' "c": {', ' "d": 5', " }", "}"])
@@ -474,7 +515,7 @@ def test_load_json_or_yaml(string, is_path, exception, raises, result):
474515
async def test_load_json_or_yaml_from_url(rw_context, mocker, overwrite, file_type, tmpdir):
475516
called_with_auth = []
476517

477-
async def mocked_download_file(rw_context, url, abs_filename, session=None, chunk_size=128, auth=None):
518+
async def mocked_download_file(rw_context, url, abs_filename, session=None, chunk_size=128, auth=None, expected_content_type=None):
478519
called_with_auth.append(auth == "someAuth")
479520
return
480521

@@ -495,7 +536,7 @@ async def mocked_download_file(rw_context, url, abs_filename, session=None, chun
495536
async def test_load_json_or_yaml_from_url_auth(rw_context, mocker, overwrite, file_type, tmpdir):
496537
called_with_auth = []
497538

498-
async def mocked_download_file(rw_context, url, abs_filename, session=None, chunk_size=128, auth=None):
539+
async def mocked_download_file(rw_context, url, abs_filename, session=None, chunk_size=128, auth=None, expected_content_type=None):
499540
called_with_auth.append(auth == "someAuth")
500541
return
501542

0 commit comments

Comments
 (0)