Files
computer-playbook/utils/install/primitives.py
kevinveenbirkenbachandClaude Opus 5 56036be2a1 fix(install): retry transient download failures, keep 4xx fatal
A single anonymous GET for the pinned hadolint binary completed its TLS
handshake and its request, then got a clean FIN with no status line after
3.6s. download_file was a bare urlopen with no retry, and make install-lint
is a prerequisite of autoformat, lint and every lint-* target -- so one shed
socket took down the whole quality gate. The same asset downloaded fine five
times elsewhere in that run, 15s after the failure on another distro.

Retry four times with exponential backoff, catching (OSError, HTTPException).
Two names, not the obvious four: ssl.SSLError is an OSError but NOT a
URLError, so the narrow tuple would have missed it, while OSError subsumes
URLError, ConnectionError, TimeoutError and gaierror in one token.
HTTPException is needed separately because IncompleteRead is not an OSError.

HTTPError is caught FIRST and re-raised immediately for any status outside
{408,429,500,502,503,504}. That ordering is load-bearing -- HTTPError is
itself an OSError, so a broader catch first would swallow it -- and it is
what keeps this robustness rather than masking: a 404 is exactly what a
stale version pin looks like after roles/sys-lint is bumped, and it still
fails on the first attempt with the URL. The final attempt re-raises the
original with its traceback, and write_bytes stays in the else branch so a
full disk is never retried.

apt was the other uncached, unretried input in the same target (its default
Acquire::Retries is 0), so give both invocations -o Acquire::Retries=3,
matching what eight Dockerfiles in this repo already do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:12:20 +02:00

91 lines
2.5 KiB
Python

"""Shared low-level install helpers."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
from http.client import HTTPException
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.error import HTTPError
from urllib.request import urlopen
if TYPE_CHECKING:
from collections.abc import Sequence
def log(msg: str) -> None:
print(msg, flush=True)
def warn(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def which(cmd: str) -> bool:
return shutil.which(cmd) is not None
def run_privileged(cmd: Sequence[str]) -> None:
argv: list[str] = list(cmd)
if os.geteuid() != 0 and shutil.which("sudo") is not None:
argv = ["sudo", *argv]
subprocess.run(argv, check=True)
_DOWNLOAD_ATTEMPTS = 4
_RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
def ensure_dir_on_path(directory: str) -> None:
if not directory:
return
current = os.environ.get("PATH", "")
parts = current.split(os.pathsep) if current else []
if directory in parts:
return
os.environ["PATH"] = directory + (os.pathsep + current if current else "")
def download_file(url: str, output: str, *, timeout: float = 60.0) -> None:
"""Fetch *url* into *output*, retrying only transient transport failures.
Args:
url: source to read.
output: path the body is written to.
timeout: per-attempt socket timeout in seconds.
Raises:
HTTPError: immediately for any status outside _RETRYABLE_STATUS, so a
stale version pin fails on the first attempt instead of after the
backoff.
"""
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
try:
with urlopen(url, timeout=timeout) as response: # noqa: S310 - trusted release URLs only
data = response.read()
except HTTPError as err:
if err.code not in _RETRYABLE_STATUS or attempt == _DOWNLOAD_ATTEMPTS:
raise
reason = f"HTTP {err.code}"
except (OSError, HTTPException) as err:
if attempt == _DOWNLOAD_ATTEMPTS:
raise
reason = type(err).__name__
else:
Path(output).write_bytes(data)
return
warn(f"{url}: {reason}; retrying in {2**attempt}s")
time.sleep(2**attempt)
def install_with_optional_sudo(cmd: Sequence[str]) -> None:
argv = list(cmd)
try:
subprocess.run(argv, check=True)
except subprocess.CalledProcessError:
run_privileged(argv)