Fetch CPython from an Astral mirror by default (#18207)
This commit is contained in:
committed by
GitHub
parent
34b7734324
commit
ce4b9a410a
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user