Fetch CPython from an Astral mirror by default (#18207)

This commit is contained in:
Zsolt Dollenstein
2026-03-03 12:02:09 +00:00
committed by GitHub
parent 34b7734324
commit ce4b9a410a
5 changed files with 235 additions and 60 deletions
+206 -44
View File
@@ -17,10 +17,10 @@ use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufWriter, ReadBuf};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tokio_util::either::Either;
use tracing::{debug, instrument};
use tracing::{debug, instrument, warn};
use url::Url;
use uv_client::{BaseClient, RetryState, WrappedReqwestError};
use uv_client::{BaseClient, RetryState, WrappedReqwestError, retryable_on_request_failure};
use uv_distribution_filename::{ExtensionError, SourceDistExtension};
use uv_extract::hash::Hasher;
use uv_fs::{Simplified, rename_with_retry};
@@ -114,6 +114,8 @@ pub enum Error {
},
#[error(transparent)]
BuildVersion(#[from] BuildVersionError),
#[error("No download URL found for Python")]
NoPythonDownloadUrlFound,
}
impl Error {
@@ -136,8 +138,41 @@ impl Error {
}
0
}
/// Returns `true` if trying an alternative URL makes sense after this error.
///
/// HTTP-level failures (4xx, 5xx) and connection-level failures return `true`.
/// Hash mismatches, extraction failures, and similar post-download errors return `false`
/// because switching to a different host would not fix them.
fn should_try_next_url(&self) -> bool {
match self {
// There are two primary reasons to try an alternative URL:
// - HTTP/DNS/TCP/etc errors due to a mirror being blocked at various layers
// - HTTP 404s from the mirror, which may mean the next URL still works
// So we catch all network-level errors here.
Self::NetworkError(..)
| Self::NetworkMiddlewareError(..)
| Self::NetworkErrorWithRetries { .. } => true,
// `Io` uses `#[error(transparent)]`, so `source()` delegates to the inner error's
// own source rather than returning the `io::Error` itself. We must unwrap it
// explicitly so that `retryable_on_request_failure` can inspect the io error kind.
Self::Io(err) => retryable_on_request_failure(err).is_some(),
_ => false,
}
}
}
/// The URL prefix used by `python-build-standalone` releases on GitHub.
const CPYTHON_DOWNLOADS_URL_PREFIX: &str =
"https://github.com/astral-sh/python-build-standalone/releases/download/";
/// The default Astral mirror for `python-build-standalone` releases.
///
/// This mirror is tried first for CPython downloads when no user-configured mirror is set.
/// If the mirror fails, uv falls back to the canonical GitHub URL.
const CPYTHON_DOWNLOAD_DEFAULT_MIRROR: &str =
"https://releases.astral.sh/github/python-build-standalone/releases/download/";
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub struct ManagedPythonDownload {
key: PythonInstallationKey,
@@ -1092,6 +1127,10 @@ impl ManagedPythonDownload {
}
/// Download and extract a Python distribution, retrying on failure.
///
/// For CPython without a user-configured mirror, the default Astral mirror is tried first.
/// Each attempt tries all URLs in sequence without backoff between them; backoff is only
/// applied after all URLs have been exhausted.
#[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
pub async fn fetch_with_retry(
&self,
@@ -1104,40 +1143,54 @@ impl ManagedPythonDownload {
pypy_install_mirror: Option<&str>,
reporter: Option<&dyn Reporter>,
) -> Result<DownloadResult, Error> {
let urls = self.download_urls(python_install_mirror, pypy_install_mirror)?;
let mut retry_state = RetryState::start(
*retry_policy,
self.download_url(python_install_mirror, pypy_install_mirror)?,
// We pass in the last URL because that will trigger backoff if it fails.
urls.last().ok_or(Error::NoPythonDownloadUrlFound)?.clone(),
);
loop {
let result = self
.fetch(
client,
installation_dir,
scratch_dir,
reinstall,
python_install_mirror,
pypy_install_mirror,
reporter,
)
.await;
match result {
Ok(download_result) => return Ok(download_result),
Err(err) => {
if let Some(backoff) = retry_state.should_retry(&err, err.retries()) {
retry_state.sleep_backoff(backoff).await;
continue;
'retry: loop {
for (i, url) in urls.iter().enumerate() {
let is_last = i == urls.len() - 1;
match self
.fetch_from_url(
url,
client,
installation_dir,
scratch_dir,
reinstall,
reporter,
)
.await
{
Ok(download_result) => return Ok(download_result),
Err(err) => {
if !is_last && err.should_try_next_url() {
warn!(
"Failed to download `{}` from {url} ({err}); falling back to {}",
self.key(),
urls[i + 1]
);
continue;
}
// All URLs exhausted; apply the retry policy.
if let Some(backoff) = retry_state.should_retry(&err, err.retries()) {
retry_state.sleep_backoff(backoff).await;
continue 'retry;
}
return if retry_state.total_retries() > 0 {
Err(Error::NetworkErrorWithRetries {
err: Box::new(err),
retries: retry_state.total_retries(),
})
} else {
Err(err)
};
}
return if retry_state.total_retries() > 0 {
Err(Error::NetworkErrorWithRetries {
err: Box::new(err),
retries: retry_state.total_retries(),
})
} else {
Err(err)
};
}
};
}
unreachable!("download_urls() must return at least one URL");
}
}
@@ -1153,7 +1206,29 @@ impl ManagedPythonDownload {
pypy_install_mirror: Option<&str>,
reporter: Option<&dyn Reporter>,
) -> Result<DownloadResult, Error> {
let url = self.download_url(python_install_mirror, pypy_install_mirror)?;
let urls = self.download_urls(python_install_mirror, pypy_install_mirror)?;
let url = urls.first().ok_or(Error::NoPythonDownloadUrlFound)?;
self.fetch_from_url(
url,
client,
installation_dir,
scratch_dir,
reinstall,
reporter,
)
.await
}
/// Download and extract a Python distribution from the given URL.
async fn fetch_from_url(
&self,
url: &DisplaySafeUrl,
client: &BaseClient,
installation_dir: &Path,
scratch_dir: &Path,
reinstall: bool,
reporter: Option<&dyn Reporter>,
) -> Result<DownloadResult, Error> {
let path = installation_dir.join(self.key().to_string());
// If it is not a reinstall and the dir already exists, return it.
@@ -1213,13 +1288,13 @@ impl ManagedPythonDownload {
if client.connectivity().is_offline() {
return Err(Error::OfflinePythonMissing {
file: Box::new(self.key().clone()),
url: Box::new(url),
url: Box::new(url.clone()),
python_builds_dir,
});
}
self.download_archive(
&url,
url,
client,
reporter,
&python_builds_dir,
@@ -1255,7 +1330,7 @@ impl ManagedPythonDownload {
temp_dir.path().simplified_display()
);
let (reader, size) = read_url(&url, client).await?;
let (reader, size) = read_url(url, client).await?;
self.extract_reader(
reader,
temp_dir.path(),
@@ -1431,27 +1506,58 @@ impl ManagedPythonDownload {
self.key.version()
}
/// Return the [`Url`] to use when downloading the distribution. If a mirror is set via the
/// appropriate environment variable, use it instead.
/// Return the primary [`Url`] to use when downloading the distribution.
///
/// This is the first URL from [`Self::download_urls`]. For CPython without a user-configured
/// mirror, this is the default Astral mirror URL. Use [`Self::download_urls`] to get all
/// URLs including fallbacks.
pub fn download_url(
&self,
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
) -> Result<DisplaySafeUrl, Error> {
self.download_urls(python_install_mirror, pypy_install_mirror)
.map(|mut urls| urls.remove(0))
}
/// Return the ordered list of [`Url`]s to try when downloading the distribution.
///
/// For CPython without a user-configured mirror, the default Astral mirror is listed first,
/// followed by the canonical GitHub URL as a fallback.
///
/// For all other cases (user mirror explicitly set, PyPy, GraalPy, Pyodide), a single URL
/// is returned with no fallback.
pub fn download_urls(
&self,
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
) -> Result<Vec<DisplaySafeUrl>, Error> {
match self.key.implementation {
LenientImplementationName::Known(ImplementationName::CPython) => {
if let Some(mirror) = python_install_mirror {
let Some(suffix) = self.url.strip_prefix(
"https://github.com/astral-sh/python-build-standalone/releases/download/",
) else {
// User-configured mirror: use it exclusively, no automatic fallback.
let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
return Err(Error::Mirror(
EnvVars::UV_PYTHON_INSTALL_MIRROR,
self.url.to_string(),
));
};
return Ok(DisplaySafeUrl::parse(
return Ok(vec![DisplaySafeUrl::parse(
format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
)?);
)?]);
}
// No user mirror: try the default Astral mirror first, fall back to GitHub.
if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
let mirror_url = DisplaySafeUrl::parse(
format!(
"{}/{}",
CPYTHON_DOWNLOAD_DEFAULT_MIRROR.trim_end_matches('/'),
suffix
)
.as_str(),
)?;
let canonical_url = DisplaySafeUrl::parse(&self.url)?;
return Ok(vec![mirror_url, canonical_url]);
}
}
@@ -1464,16 +1570,16 @@ impl ManagedPythonDownload {
self.url.to_string(),
));
};
return Ok(DisplaySafeUrl::parse(
return Ok(vec![DisplaySafeUrl::parse(
format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
)?);
)?]);
}
}
_ => {}
}
Ok(DisplaySafeUrl::parse(&self.url)?)
Ok(vec![DisplaySafeUrl::parse(&self.url)?])
}
}
@@ -2214,4 +2320,60 @@ mod tests {
"cpython-3.12.0-linux-x86_64-gnu"
);
}
/// A hash mismatch is a post-download integrity failure — retrying a different URL cannot fix
/// it, so it should not trigger a fallback.
#[test]
fn test_should_try_next_url_hash_mismatch() {
let err = Error::HashMismatch {
installation: "cpython-3.12.0".to_string(),
expected: "abc".to_string(),
actual: "def".to_string(),
};
assert!(!err.should_try_next_url());
}
/// A local filesystem error during extraction (e.g. permission denied writing to disk) is not
/// a network failure — a different URL would produce the same outcome.
#[test]
fn test_should_try_next_url_extract_error_filesystem() {
let err = Error::ExtractError(
"archive.tar.gz".to_string(),
uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
);
assert!(!err.should_try_next_url());
}
/// A generic IO error from a local filesystem operation (e.g. permission denied on cache
/// directory) should not trigger a fallback to a different URL.
#[test]
fn test_should_try_next_url_io_error_filesystem() {
let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
assert!(!err.should_try_next_url());
}
/// A network IO error (e.g. connection reset mid-download) surfaces as `Error::Io` from
/// `download_archive`. It should trigger a fallback because a different mirror may succeed.
#[test]
fn test_should_try_next_url_io_error_network() {
let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
assert!(err.should_try_next_url());
}
/// A 404 HTTP response from the mirror becomes `Error::NetworkError` — it should trigger a
/// URL fallback, because a 404 on the mirror does not mean the file is absent from GitHub.
#[test]
fn test_should_try_next_url_network_error_404() {
let url =
DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
.unwrap();
// `NetworkError` wraps a `WrappedReqwestError`; we use a middleware error as a
// stand-in because `should_try_next_url` only inspects the variant, not the contents.
let wrapped = WrappedReqwestError::with_problem_details(
reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
None,
);
let err = Error::NetworkError(url, wrapped);
assert!(err.should_try_next_url());
}
}