From 9ae78aef6086428bedd2fad6bf2d31aef13dd201 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Wed, 28 Oct 2026 13:51:47 +0000 Subject: [PATCH] Retry transient iOS bundle package downloads --- frame/kdeconnect/fetch.py | 27 ++++++++++++++--- tests/test_kdeconnect_fetch.py | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/test_kdeconnect_fetch.py diff --git a/frame/kdeconnect/fetch.py b/frame/kdeconnect/fetch.py index 9e7aa14..4598ec8 100644 --- a/frame/kdeconnect/fetch.py +++ b/frame/kdeconnect/fetch.py @@ -7,8 +7,11 @@ ios/scripts/make_frame_bundle.py runs this; app/build/fetch-deps.js does the sam in Node. Usage: python3 frame/kdeconnect/fetch.py """ import hashlib +import http.client import json import sys +import time +import urllib.error import urllib.request from pathlib import Path @@ -24,6 +27,25 @@ def sha256(data): return hashlib.sha256(data).hexdigest() +def download_verified(url, expected_sha256): + for attempt in range(3): + try: + with urllib.request.urlopen(url, timeout=120) as response: + data = response.read() + except (OSError, http.client.HTTPException) as error: + # GitHub release downloads occasionally close during connection or + # transfer. Retry those failures, but fail fast on permanent HTTP errors. + if isinstance(error, urllib.error.HTTPError) and error.code not in (408, 429) and error.code < 500: + raise + if attempt == 2: + raise + time.sleep(attempt + 1) + continue + if sha256(data) != expected_sha256: + raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}") + return data + + def fetch(): m = manifest() PACKAGES.mkdir(exist_ok=True) @@ -32,10 +54,7 @@ def fetch(): if path.is_file() and sha256(path.read_bytes()) == p["sha256"]: continue url = m["release"] + p["file"] - with urllib.request.urlopen(url, timeout=120) as r: - data = r.read() - if sha256(data) != p["sha256"]: - raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}") + data = download_verified(url, p["sha256"]) tmp = path.with_suffix(".part") tmp.write_bytes(data) tmp.replace(path) diff --git a/tests/test_kdeconnect_fetch.py b/tests/test_kdeconnect_fetch.py new file mode 100644 index 0000000..c5a1194 --- /dev/null +++ b/tests/test_kdeconnect_fetch.py @@ -0,0 +1,53 @@ +"""The iOS Frame bundle must recover transient pinned package downloads.""" +import hashlib +import http.client +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch +import urllib.error +import io + + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location('kdeconnect_fetch', ROOT / 'frame/kdeconnect/fetch.py') +fetch = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(fetch) + + +class VerifiedDownload(unittest.TestCase): + def test_remote_disconnect_retries_then_checks_hash(self): + data = b'pinned package' + digest = hashlib.sha256(data).hexdigest() + with patch.object(fetch.urllib.request, 'urlopen', side_effect=[ + http.client.RemoteDisconnected('closed'), io.BytesIO(data)]) as open_url, \ + patch.object(fetch.time, 'sleep') as sleep: + self.assertEqual(fetch.download_verified('https://example.invalid/package', digest), data) + self.assertEqual(open_url.call_count, 2) + sleep.assert_called_once_with(1) + + def test_permanent_http_error_and_wrong_hash_fail_closed(self): + url = 'https://example.invalid/package' + missing = urllib.error.HTTPError(url, 404, 'missing', {}, None) + with patch.object(fetch.urllib.request, 'urlopen', side_effect=missing) as open_url, \ + patch.object(fetch.time, 'sleep') as sleep: + with self.assertRaises(urllib.error.HTTPError): + fetch.download_verified(url, '0' * 64) + open_url.assert_called_once() + sleep.assert_not_called() + with patch.object(fetch.urllib.request, 'urlopen', return_value=io.BytesIO(b'changed')) as open_url: + with self.assertRaisesRegex(SystemExit, 'checksum mismatch'): + fetch.download_verified(url, '0' * 64) + open_url.assert_called_once() + + def test_exhausted_transient_error_is_not_silenced(self): + with patch.object(fetch.urllib.request, 'urlopen', side_effect=http.client.RemoteDisconnected('closed')) as open_url, \ + patch.object(fetch.time, 'sleep') as sleep: + with self.assertRaises(http.client.RemoteDisconnected): + fetch.download_verified('https://example.invalid/package', '0' * 64) + self.assertEqual(open_url.call_count, 3) + self.assertEqual(sleep.call_count, 2) + + +if __name__ == '__main__': + unittest.main()