diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index e11845adb..9ddc30e75 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::Duration; use std::{env, io, iter}; +use anyhow::Context; use anyhow::anyhow; use http::{ HeaderMap, HeaderName, HeaderValue, Method, StatusCode, @@ -166,6 +167,25 @@ impl<'a> BaseClientBuilder<'a> { self } + /// Read the retry count from [`EnvVars::UV_HTTP_RETRIES`] if set, otherwise, make no change. + /// + /// Errors when [`EnvVars::UV_HTTP_RETRIES`] is not a valid u32. + pub fn retries_from_env(self) -> anyhow::Result { + // TODO(zanieb): We should probably parse this in another layer, but there's not a natural + // fit for it right now + if let Some(value) = env::var_os(EnvVars::UV_HTTP_RETRIES) { + Ok(self.retries( + value + .to_string_lossy() + .as_ref() + .parse::() + .context("Failed to parse `UV_HTTP_RETRIES`")?, + )) + } else { + Ok(self) + } + } + #[must_use] pub fn native_tls(mut self, native_tls: bool) -> Self { self.native_tls = native_tls; @@ -238,7 +258,11 @@ impl<'a> BaseClientBuilder<'a> { /// Create a [`RetryPolicy`] for the client. fn retry_policy(&self) -> ExponentialBackoff { - ExponentialBackoff::builder().build_with_max_retries(self.retries) + let mut builder = ExponentialBackoff::builder(); + if env::var_os(EnvVars::UV_TEST_NO_HTTP_RETRY_DELAY).is_some() { + builder = builder.retry_bounds(Duration::from_millis(0), Duration::from_millis(0)); + } + builder.build_with_max_retries(self.retries) } pub fn build(&self) -> BaseClient { diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index c7694676c..afa1b03ae 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -115,6 +115,11 @@ impl<'a> RegistryClientBuilder<'a> { self } + pub fn retries_from_env(mut self) -> anyhow::Result { + self.base_client_builder = self.base_client_builder.retries_from_env()?; + Ok(self) + } + #[must_use] pub fn native_tls(mut self, native_tls: bool) -> Self { self.base_client_builder = self.base_client_builder.native_tls(native_tls); diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs index 812f9141f..68fe84abc 100644 --- a/crates/uv-requirements/src/lib.rs +++ b/crates/uv-requirements/src/lib.rs @@ -31,6 +31,9 @@ pub enum Error { #[error(transparent)] WheelFilename(#[from] uv_distribution_filename::WheelFilenameError), + #[error("Failed to construct HTTP client")] + ClientError(#[source] anyhow::Error), + #[error(transparent)] Io(#[from] std::io::Error), } diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 4ac2976d9..5b91fccea 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -402,6 +402,9 @@ impl EnvVars { /// Timeout (in seconds) for HTTP requests. (default: 30 s) pub const UV_HTTP_TIMEOUT: &'static str = "UV_HTTP_TIMEOUT"; + /// The number of retries for HTTP requests. (default: 3) + pub const UV_HTTP_RETRIES: &'static str = "UV_HTTP_RETRIES"; + /// Timeout (in seconds) for HTTP requests. Equivalent to `UV_HTTP_TIMEOUT`. pub const UV_REQUEST_TIMEOUT: &'static str = "UV_REQUEST_TIMEOUT"; @@ -659,6 +662,9 @@ impl EnvVars { #[attr_hidden] pub const UV_TEST_VENDOR_LINKS_URL: &'static str = "UV_TEST_VENDOR_LINKS_URL"; + /// Used to disable delay for HTTP retries in tests. + pub const UV_TEST_NO_HTTP_RETRY_DELAY: &'static str = "UV_TEST_NO_HTTP_RETRY_DELAY"; + /// Used to set an index url for tests. #[attr_hidden] pub const UV_TEST_INDEX_URL: &'static str = "UV_TEST_INDEX_URL"; diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 2cef9a406..fd6ed73d7 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -207,6 +207,7 @@ async fn build_impl( } = settings; let client_builder = BaseClientBuilder::default() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index a1846d418..c40716763 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -179,6 +179,7 @@ pub(crate) async fn pip_compile( } let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index aa6e6a6c9..bbfe99c50 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -99,6 +99,7 @@ pub(crate) async fn pip_install( let start = std::time::Instant::now(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index 356574436..40e8c770d 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -87,6 +87,7 @@ pub(crate) async fn pip_list( let capabilities = IndexCapabilities::default(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 8f26aaea2..6858ddad0 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -81,6 +81,7 @@ pub(crate) async fn pip_sync( preview: PreviewMode, ) -> Result { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index b0ba44c35..81a566b8e 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -86,6 +86,7 @@ pub(crate) async fn pip_tree( let capabilities = IndexCapabilities::default(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 835e7de65..f617a0203 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -42,6 +42,7 @@ pub(crate) async fn pip_uninstall( let start = std::time::Instant::now(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index f255194de..d65866483 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -176,6 +176,7 @@ pub(crate) async fn add( } let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -329,6 +330,7 @@ pub(crate) async fn add( .ok(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(settings.resolver.keyring_provider) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 15fed409e..9ff321a72 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -218,6 +218,7 @@ async fn init_script( warn_user_once!("`--package` is a no-op for Python scripts, which are standalone"); } let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -348,6 +349,7 @@ async fn init_project( let reporter = PythonDownloadReporter::single(printer); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 9cbd43ea9..f79557d9e 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -99,6 +99,7 @@ pub(crate) async fn lock( let script = match script { Some(ScriptPath::Path(path)) => { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -588,6 +589,7 @@ async fn do_lock( // Initialize the client. let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(*keyring_provider) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index a012e2855..1a0274cac 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -690,6 +690,7 @@ impl ScriptInterpreter { } let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -946,6 +947,7 @@ impl ProjectInterpreter { } let client_builder = BaseClientBuilder::default() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -1656,6 +1658,8 @@ pub(crate) async fn resolve_names( } = settings; let client_builder = BaseClientBuilder::new() + .retries_from_env() + .map_err(uv_requirements::Error::ClientError)? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(*keyring_provider) @@ -1813,6 +1817,7 @@ pub(crate) async fn resolve_environment( } = spec.requirements; let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(*keyring_provider) @@ -1984,6 +1989,7 @@ pub(crate) async fn sync_environment( } = settings; let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) @@ -2147,6 +2153,7 @@ pub(crate) async fn update_environment( } = settings; let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(*keyring_provider) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index f0a46f16a..3eece5432 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -618,6 +618,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // If we're isolating the environment, use an ephemeral virtual environment as the // base environment for the project. let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -859,6 +860,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl let interpreter = { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -929,6 +931,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl None } else { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -1526,6 +1529,7 @@ impl RunCommand { .tempfile()?; let client = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()) diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 664eb2a94..a9a161527 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -623,6 +623,7 @@ pub(super) async fn do_sync( } = settings; let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .keyring(keyring_provider) diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index d401940d9..cd1339d3e 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -215,6 +215,7 @@ pub(crate) async fn tree( let client = RegistryClientBuilder::new( cache.clone().with_refresh(Refresh::All(Timestamp::now())), ) + .retries_from_env()? .native_tls(network_settings.native_tls) .connectivity(network_settings.connectivity) .allow_insecure_host(network_settings.allow_insecure_host.clone()) diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index 63a0f2756..e7f5e00a2 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -95,6 +95,7 @@ pub(crate) async fn publish( false, ); let registry_client_builder = RegistryClientBuilder::new(cache.clone()) + .retries_from_env()? .native_tls(network_settings.native_tls) .connectivity(network_settings.connectivity) .allow_insecure_host(network_settings.allow_insecure_host.clone()) diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 3df0cf91d..8c8387d07 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -376,6 +376,7 @@ pub(crate) async fn install( // Download and unpack the Python versions concurrently let client = uv_client::BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()) diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 395981751..f4d10cdfa 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -107,6 +107,7 @@ pub(crate) async fn pin( } let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 5ced211b3..27f18abe4 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -66,6 +66,7 @@ pub(crate) async fn install( preview: PreviewMode, ) -> Result { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); @@ -97,6 +98,7 @@ pub(crate) async fn install( let workspace_cache = WorkspaceCache::default(); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 2746d65ad..c8297243d 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -690,6 +690,7 @@ async fn get_or_create_environment( preview: PreviewMode, ) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> { let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 95b7d1e2d..9d2d32a21 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -80,6 +80,7 @@ pub(crate) async fn upgrade( let reporter = PythonDownloadReporter::single(printer); let client_builder = BaseClientBuilder::new() + .retries_from_env()? .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) .allow_insecure_host(network_settings.allow_insecure_host.clone()); diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 9334d844d..6d6e15758 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -193,6 +193,9 @@ async fn venv_impl( .unwrap_or(PathBuf::from(".venv")), ); + // TODO(zanieb): We don't use [`BaseClientBuilder::retries_from_env`] here because it's a pain + // to map into a miette diagnostic. We should just remove miette diagnostics here, we're not + // using them elsewhere. let client_builder = BaseClientBuilder::default() .connectivity(network_settings.connectivity) .native_tls(network_settings.native_tls) diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index f231198e4..f142beefa 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -499,6 +499,66 @@ fn install_package() { context.assert_command("import flask").success(); } +#[tokio::test] +async fn install_http_retries() { + let context = TestContext::new("3.12"); + + let server = MockServer::start().await; + + // Create a server that always fails, so we can see the number of retries used + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio") + .arg("--index") + .arg(server.uri()) + .env(EnvVars::UV_HTTP_RETRIES, "foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse `UV_HTTP_RETRIES` + Caused by: invalid digit found in string + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio") + .arg("--index") + .arg(server.uri()) + .env(EnvVars::UV_HTTP_RETRIES, "999999999999"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse `UV_HTTP_RETRIES` + Caused by: number too large to fit in target type + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio") + .arg("--index") + .arg(server.uri()) + .env(EnvVars::UV_HTTP_RETRIES, "5") + .env(EnvVars::UV_TEST_NO_HTTP_RETRY_DELAY, "true"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Request failed after 5 retries + Caused by: Failed to fetch: `http://[LOCALHOST]/anyio/` + Caused by: HTTP status server error (503 Service Unavailable) for url (http://[LOCALHOST]/anyio/) + " + ); +} + /// Install a package from a `requirements.txt` into a virtual environment. #[test] fn install_requirements_txt() -> Result<()> { diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 61889ddb3..bf8bf29ec 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -102,6 +102,10 @@ Equivalent to the `--token` argument for self update. A GitHub token for authent Enables fetching files stored in Git LFS when installing a package from a Git repository. +### `UV_HTTP_RETRIES` + +The number of retries for HTTP requests. (default: 3) + ### `UV_HTTP_TIMEOUT` Timeout (in seconds) for HTTP requests. (default: 30 s) @@ -416,6 +420,10 @@ WARNING: `UV_SYSTEM_PYTHON=true` is intended for use in continuous integration ( or containerized environments and should be used with caution, as modifying the system Python can lead to unexpected behavior. +### `UV_TEST_NO_HTTP_RETRY_DELAY` + +Used to disable delay for HTTP retries in tests. + ### `UV_TOOL_BIN_DIR` Specifies the "bin" directory for installing tool executables.