Files
datastore/tests/ci/build_download_helper.py
Alexey MilovidovandClaude Opus 4.8 3fd2079b42 Make previous-release package downloads resilient to infra flakes
The Upgrade check downloads the previous stable release `.deb` packages
during setup. A transient hiccup that exhausted the small retry budget
there (5 attempts, ~45s of total backoff) used to abort the whole job in
the setup phase before the actual test ran, and was recorded only as a
generic `Download script exit code` with an `OK` status - making it hard
to attribute as infrastructure noise.

Improve `download_build_with_progress`:
- accept a configurable `retries` count;
- cap the exponential backoff (`DOWNLOAD_RETRY_MAX_BACKOFF`) so raising
  the retry count extends the total retry window instead of exploding the
  per-attempt sleep;
- fail fast on a genuine `404` (the artifact was never uploaded) instead
  of burning the whole retry budget;
- treat a truncated response (short read vs. `content-length`) as a
  transient failure rather than producing a corrupt file that only
  surfaces much later as an opaque `dpkg` error;
- log the HTTP status / error and attempt number on each retry.

In `download_packages` (`download_release_packages`):
- retry the release packages more persistently
  (`RELEASE_PACKAGE_DOWNLOAD_RETRIES`);
- stop silently skipping a missing essential package (the ones
  `install_packages` installs) - raise a clear, attributable error that
  distinguishes a genuine `404` from a transient failure, while still
  tolerating a hiccup on unused extra assets such as `clickhouse-keeper`.

Add unit tests for both.

https://github.com/ClickHouse/ClickHouse/pull/102817#issuecomment-4726000791

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-18 20:49:37 +00:00

205 lines
7.0 KiB
Python

#!/usr/bin/env python3
import logging
import sys
import time
from pathlib import Path
from typing import Any
import requests
try:
# A work around for scripts using this downloading module without required deps
import get_robot_token as grt # we need an updated ROBOT_TOKEN
except ImportError:
class grt: # type: ignore
ROBOT_TOKEN = None
@staticmethod
def get_best_robot_token() -> str:
return ""
DOWNLOAD_RETRIES_COUNT = 5
# Cap the exponential backoff so that increasing the number of retries extends
# the total time we keep trying through a transient outage instead of exploding
# the per-attempt sleep.
DOWNLOAD_RETRY_MAX_BACKOFF = 60
logger = logging.getLogger(__name__)
class DownloadException(Exception):
pass
class APIException(Exception):
pass
def get_with_retries(
url: str,
retries: int = DOWNLOAD_RETRIES_COUNT,
sleep: int = 3,
**kwargs: Any,
) -> requests.Response:
logger.info(
"Getting URL with %i tries and sleep %i in between: %s", retries, sleep, url
)
exc = Exception("A placeholder to satisfy typing and avoid nesting")
timeout = kwargs.pop("timeout", 30)
for i in range(retries):
try:
response = requests.get(url, timeout=timeout, **kwargs)
response.raise_for_status()
return response
except Exception as e:
if i + 1 < retries:
logger.info("Exception '%s' while getting, retry %i", e, i + 1)
time.sleep(sleep)
exc = e
raise exc
def get_gh_api(
url: str,
retries: int = DOWNLOAD_RETRIES_COUNT,
sleep: int = 3,
**kwargs: Any,
) -> requests.Response:
"""
Request GH api w/o auth by default, and failover to the get_best_robot_token in case of receiving
"403 rate limit exceeded" or "404 not found" error
It sets auth automatically when ROBOT_TOKEN is already set by get_best_robot_token
"""
def set_auth_header():
headers = kwargs.get("headers", {})
if "Authorization" not in headers:
headers["Authorization"] = f"Bearer {grt.get_best_robot_token()}"
kwargs["headers"] = headers
if grt.ROBOT_TOKEN is not None:
set_auth_header()
token_is_set = "Authorization" in kwargs.get("headers", {})
exc = Exception("A placeholder to satisfy typing and avoid nesting")
try_cnt = 0
timeout = kwargs.pop("timeout", 30)
while try_cnt < retries:
try_cnt += 1
try:
response = requests.get(url, timeout=timeout, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
exc = e
ratelimit_exceeded = (
e.response.status_code == 403
and b"rate limit exceeded"
# pylint:disable-next=protected-access
in (e.response._content or b"")
)
try_auth = e.response.status_code == 404
if (ratelimit_exceeded or try_auth) and not token_is_set:
logger.warning(
"Received rate limit exception, setting the auth header and retry"
)
set_auth_header()
token_is_set = True
try_cnt = 0
continue
except Exception as e:
exc = e
if try_cnt < retries:
logger.info("Exception '%s' while getting, retry %i", exc, try_cnt)
time.sleep(sleep)
raise APIException(f"Unable to request data from GH API: {url}") from exc
def download_build_with_progress(
url: str, path: Path, retries: int = DOWNLOAD_RETRIES_COUNT
) -> None:
logger.info("Downloading from %s to temp path %s", url, path)
for i in range(retries):
try:
response = get_with_retries(url, retries=1, stream=True)
total_length = int(response.headers.get("content-length", 0))
if path.is_file() and total_length and path.stat().st_size == total_length:
logger.info(
"The file %s already exists and have a proper size %s",
path,
total_length,
)
return
with open(path, "wb") as f:
dl = 0
if total_length == 0:
logger.info(
"No content-length, will download file without progress"
)
content = response.content
dl = len(content)
f.write(content)
else:
logger.info("Content length is %ld bytes", total_length)
for data in response.iter_content(chunk_size=4096):
dl += len(data)
f.write(data)
if sys.stdout.isatty():
done = int(50 * dl / total_length)
percent = int(100 * float(dl) / total_length)
eq_str = "=" * done
space_str = " " * (50 - done)
sys.stdout.write(f"\r[{eq_str}{space_str}] {percent}%")
sys.stdout.flush()
# A truncated response is a transient failure too: without this check a
# short read produces a corrupt file that only fails much later (e.g. as
# an opaque `dpkg` error), hiding the real download problem.
if total_length and dl != total_length:
raise DownloadException(
f"Downloaded {dl} of {total_length} bytes from {url}"
)
break
except Exception as e:
if sys.stdout.isatty():
sys.stdout.write("\n")
if path.exists():
path.unlink()
# A 404 means the artifact genuinely does not exist (e.g. packages were
# not uploaded for this tag yet). Retrying cannot help, so fail fast with
# a clear, attributable message instead of burning the whole retry budget.
status_code = getattr(getattr(e, "response", None), "status_code", None)
if status_code == 404:
raise DownloadException(
f"Cannot download {url}: the file does not exist (HTTP 404)"
) from e
if i + 1 < retries:
sleep_time = min(3 * (2**i), DOWNLOAD_RETRY_MAX_BACKOFF)
logger.warning(
"Download attempt %i of %i for %s failed (%s), retrying in %i seconds",
i + 1,
retries,
url,
e,
sleep_time,
)
time.sleep(sleep_time)
else:
raise DownloadException(
f"Cannot download {url}, all {retries} retries exceeded; "
f"last error: {e}"
) from e
if sys.stdout.isatty():
sys.stdout.write("\n")
logger.info("Downloading finished")