Retry streaming Python and bin download errors (#15567)
Some checks are pending
CI / typos (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | aarch64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux aarch64 (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / build binary | msrv (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / smoke test | linux aarch64 (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | aarch64 windows implicit (push) Blocked by required conditions
CI / integration test | aarch64 windows explicit (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | pyodide on ubuntu (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | pyenv on wsl x86-64 (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | registries (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | graalpy on ubuntu (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | aarch64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks | walltime aarch64 linux (push) Blocked by required conditions
CI / benchmarks | instrumented (push) Blocked by required conditions
zizmor / Run zizmor (push) Waiting to run

When there is an error during the streaming download and unpack for
Python interpreter and bin installs, we would previously fail, causing a
lot of CI flakes on GitHub Actions.

The problem was that the error is not one of the extended IO errors we
were previously handling, but a regular reqwest error, nested below
layers of errors of other crates processing the stream, including some
IO errors. We now handle nested reqwest errors, too.

This surfaced another problem: Our manual retry loop couldn't inform the
retry middleware that it already performed the limit of retries, and
that the middleware should not retry anymore. While too many retries are
more a problem for debugging than for the user, this causes confusing
error output. To work around this, we disable the retries in the client
and handle all retry errors in our loop.

Fixes https://github.com/astral-sh/uv/issues/14171

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
konsti 2025-08-31 17:07:22 +02:00 committed by GitHub
parent 01e5195ef3
commit 22f80ca00d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 144 additions and 71 deletions

9
Cargo.lock generated
View file

@ -3358,21 +3358,21 @@ dependencies = [
[[package]] [[package]]
name = "reqwest-middleware" name = "reqwest-middleware"
version = "0.4.2" version = "0.4.2"
source = "git+https://github.com/astral-sh/reqwest-middleware?rev=ad8b9d332d1773fde8b4cd008486de5973e0a3f8#ad8b9d332d1773fde8b4cd008486de5973e0a3f8" source = "git+https://github.com/astral-sh/reqwest-middleware?rev=7650ed76215a962a96d94a79be71c27bffde7ab2#7650ed76215a962a96d94a79be71c27bffde7ab2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"http", "http",
"reqwest", "reqwest",
"serde", "serde",
"thiserror 1.0.69", "thiserror 2.0.16",
"tower-service", "tower-service",
] ]
[[package]] [[package]]
name = "reqwest-retry" name = "reqwest-retry"
version = "0.7.0" version = "0.7.0"
source = "git+https://github.com/astral-sh/reqwest-middleware?rev=ad8b9d332d1773fde8b4cd008486de5973e0a3f8#ad8b9d332d1773fde8b4cd008486de5973e0a3f8" source = "git+https://github.com/astral-sh/reqwest-middleware?rev=7650ed76215a962a96d94a79be71c27bffde7ab2#7650ed76215a962a96d94a79be71c27bffde7ab2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -3383,7 +3383,7 @@ dependencies = [
"reqwest", "reqwest",
"reqwest-middleware", "reqwest-middleware",
"retry-policies", "retry-policies",
"thiserror 1.0.69", "thiserror 2.0.16",
"tokio", "tokio",
"tracing", "tracing",
"wasmtimer", "wasmtimer",
@ -4990,6 +4990,7 @@ dependencies = [
"predicates", "predicates",
"regex", "regex",
"reqwest", "reqwest",
"reqwest-retry",
"rkyv", "rkyv",
"rustc-hash", "rustc-hash",
"self-replace", "self-replace",

View file

@ -149,8 +149,8 @@ reflink-copy = { version = "0.1.19" }
regex = { version = "1.10.6" } regex = { version = "1.10.6" }
regex-automata = { version = "0.4.8", default-features = false, features = ["dfa-build", "dfa-search", "perf", "std", "syntax"] } regex-automata = { version = "0.4.8", default-features = false, features = ["dfa-build", "dfa-search", "perf", "std", "syntax"] }
reqwest = { version = "0.12.22", default-features = false, features = ["json", "gzip", "deflate", "zstd", "stream", "system-proxy", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart", "http2", "blocking"] } reqwest = { version = "0.12.22", default-features = false, features = ["json", "gzip", "deflate", "zstd", "stream", "system-proxy", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart", "http2", "blocking"] }
reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8", features = ["multipart"] } reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2", features = ["multipart"] }
reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8" } reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" }
rkyv = { version = "0.8.8", features = ["bytecheck"] } rkyv = { version = "0.8.8", features = ["bytecheck"] }
rmp-serde = { version = "1.3.0" } rmp-serde = { version = "1.3.0" }
rust-netrc = { version = "0.1.2" } rust-netrc = { version = "0.1.2" }
@ -322,5 +322,5 @@ codegen-units = 1
inherits = "release" inherits = "release"
[patch.crates-io] [patch.crates-io]
reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8" } reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" }
reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8" } reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" }

View file

@ -10,6 +10,7 @@ use std::time::{Duration, SystemTime};
use futures::TryStreamExt; use futures::TryStreamExt;
use reqwest_retry::RetryPolicy; use reqwest_retry::RetryPolicy;
use reqwest_retry::policies::ExponentialBackoff;
use std::fmt; use std::fmt;
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncRead, ReadBuf}; use tokio::io::{AsyncRead, ReadBuf};
@ -19,7 +20,7 @@ use url::Url;
use uv_distribution_filename::SourceDistExtension; use uv_distribution_filename::SourceDistExtension;
use uv_cache::{Cache, CacheBucket, CacheEntry}; use uv_cache::{Cache, CacheBucket, CacheEntry};
use uv_client::{BaseClient, is_extended_transient_error}; use uv_client::{BaseClient, is_transient_network_error};
use uv_extract::{Error as ExtractError, stream}; use uv_extract::{Error as ExtractError, stream};
use uv_pep440::Version; use uv_pep440::Version;
use uv_platform::Platform; use uv_platform::Platform;
@ -160,6 +161,7 @@ pub async fn bin_install(
binary: Binary, binary: Binary,
version: &Version, version: &Version,
client: &BaseClient, client: &BaseClient,
retry_policy: &ExponentialBackoff,
cache: &Cache, cache: &Cache,
reporter: &dyn Reporter, reporter: &dyn Reporter,
) -> Result<PathBuf, Error> { ) -> Result<PathBuf, Error> {
@ -195,6 +197,7 @@ pub async fn bin_install(
binary, binary,
version, version,
client, client,
retry_policy,
cache, cache,
reporter, reporter,
&platform_name, &platform_name,
@ -227,6 +230,7 @@ async fn download_and_unpack_with_retry(
binary: Binary, binary: Binary,
version: &Version, version: &Version,
client: &BaseClient, client: &BaseClient,
retry_policy: &ExponentialBackoff,
cache: &Cache, cache: &Cache,
reporter: &dyn Reporter, reporter: &dyn Reporter,
platform_name: &str, platform_name: &str,
@ -237,7 +241,6 @@ async fn download_and_unpack_with_retry(
let mut total_attempts = 0; let mut total_attempts = 0;
let mut retried_here = false; let mut retried_here = false;
let start_time = SystemTime::now(); let start_time = SystemTime::now();
let retry_policy = client.retry_policy();
loop { loop {
let result = download_and_unpack( let result = download_and_unpack(
@ -259,7 +262,7 @@ async fn download_and_unpack_with_retry(
total_attempts += err.attempts(); total_attempts += err.attempts();
let past_retries = total_attempts - 1; let past_retries = total_attempts - 1;
if is_extended_transient_error(&err) { if is_transient_network_error(&err) {
let retry_decision = retry_policy.should_retry(start_time, past_retries); let retry_decision = retry_policy.should_retry(start_time, past_retries);
if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision { if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision {
debug!( debug!(

View file

@ -21,6 +21,7 @@ use reqwest_middleware::{ClientWithMiddleware, Middleware};
use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::policies::ExponentialBackoff;
use reqwest_retry::{ use reqwest_retry::{
DefaultRetryableStrategy, RetryTransientMiddleware, Retryable, RetryableStrategy, DefaultRetryableStrategy, RetryTransientMiddleware, Retryable, RetryableStrategy,
default_on_request_error,
}; };
use thiserror::Error; use thiserror::Error;
use tracing::{debug, trace}; use tracing::{debug, trace};
@ -38,10 +39,10 @@ use uv_static::EnvVars;
use uv_version::version; use uv_version::version;
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use crate::Connectivity;
use crate::linehaul::LineHaul; use crate::linehaul::LineHaul;
use crate::middleware::OfflineMiddleware; use crate::middleware::OfflineMiddleware;
use crate::tls::read_identity; use crate::tls::read_identity;
use crate::{Connectivity, WrappedReqwestError};
/// Do not use this value directly outside tests, use [`retries_from_env`] instead. /// Do not use this value directly outside tests, use [`retries_from_env`] instead.
pub const DEFAULT_RETRIES: u32 = 3; pub const DEFAULT_RETRIES: u32 = 3;
@ -916,7 +917,7 @@ impl RetryableStrategy for UvRetryableStrategy {
None | Some(Retryable::Fatal) None | Some(Retryable::Fatal)
if res if res
.as_ref() .as_ref()
.is_err_and(|err| is_extended_transient_error(err)) => .is_err_and(|err| is_transient_network_error(err)) =>
{ {
Some(Retryable::Transient) Some(Retryable::Transient)
} }
@ -944,12 +945,15 @@ impl RetryableStrategy for UvRetryableStrategy {
} }
} }
/// Check for additional transient error kinds not supported by the default retry strategy in `reqwest_retry`. /// Whether the error looks like a network error that should be retried.
/// ///
/// These cases should be safe to retry with [`Retryable::Transient`]. /// There are two cases that the default retry strategy is missing:
pub fn is_extended_transient_error(err: &dyn Error) -> bool { /// * Inside the reqwest middleware error is an `io::Error` such as a broken pipe
/// * When streaming a response, a reqwest error may be hidden several layers behind errors
/// of different crates processing the stream, including `io::Error` layers.
pub fn is_transient_network_error(err: &(dyn Error + 'static)) -> bool {
// First, try to show a nice trace log // First, try to show a nice trace log
if let Some((Some(status), Some(url))) = find_source::<crate::WrappedReqwestError>(&err) if let Some((Some(status), Some(url))) = find_source::<WrappedReqwestError>(&err)
.map(|request_err| (request_err.status(), request_err.url())) .map(|request_err| (request_err.status(), request_err.url()))
{ {
trace!("Considering retry of response HTTP {status} for {url}"); trace!("Considering retry of response HTTP {status} for {url}");
@ -957,10 +961,39 @@ pub fn is_extended_transient_error(err: &dyn Error) -> bool {
trace!("Considering retry of error: {err:?}"); trace!("Considering retry of error: {err:?}");
} }
// IO Errors may be nested through custom IO errors. let mut has_known_error = false;
let mut has_io_error = false; // IO Errors or reqwest errors may be nested through custom IO errors or stream processing
for io_err in find_sources::<io::Error>(&err) { // crates
has_io_error = true; let mut current_source = err.source();
while let Some(source) = current_source {
if let Some(reqwest_err) = source.downcast_ref::<WrappedReqwestError>() {
has_known_error = true;
if let reqwest_middleware::Error::Reqwest(reqwest_err) = &**reqwest_err {
if default_on_request_error(reqwest_err) == Some(Retryable::Transient) {
trace!("Retrying nested reqwest middleware error");
return true;
}
if is_retryable_status_error(reqwest_err) {
trace!("Retrying nested reqwest middleware status code error");
return true;
}
}
trace!("Cannot retry nested reqwest middleware error");
} else if let Some(reqwest_err) = source.downcast_ref::<reqwest::Error>() {
has_known_error = true;
if default_on_request_error(reqwest_err) == Some(Retryable::Transient) {
trace!("Retrying nested reqwest error");
return true;
}
if is_retryable_status_error(reqwest_err) {
trace!("Retrying nested reqwest status code error");
return true;
}
trace!("Cannot retry nested reqwest error");
} else if let Some(io_err) = source.downcast_ref::<io::Error>() {
has_known_error = true;
let retryable_io_err_kinds = [ let retryable_io_err_kinds = [
// https://github.com/astral-sh/uv/issues/12054 // https://github.com/astral-sh/uv/issues/12054
io::ErrorKind::BrokenPipe, io::ErrorKind::BrokenPipe,
@ -977,18 +1010,34 @@ pub fn is_extended_transient_error(err: &dyn Error) -> bool {
trace!("Retrying error: `{}`", io_err.kind()); trace!("Retrying error: `{}`", io_err.kind());
return true; return true;
} }
trace!( trace!(
"Cannot retry IO error `{}`, not a retryable IO error kind", "Cannot retry IO error `{}`, not a retryable IO error kind",
io_err.kind() io_err.kind()
); );
} }
if !has_io_error { current_source = source.source();
trace!("Cannot retry error: not an extended IO error"); }
if !has_known_error {
trace!("Cannot retry error: Neither an IO error nor a reqwest error");
} }
false false
} }
/// Whether the error is a status code error that is retryable.
///
/// Port of `reqwest_retry::default_on_request_success`.
fn is_retryable_status_error(reqwest_err: &reqwest::Error) -> bool {
let Some(status) = reqwest_err.status() else {
return false;
};
status.is_server_error()
|| status == StatusCode::REQUEST_TIMEOUT
|| status == StatusCode::TOO_MANY_REQUESTS
}
/// Find the first source error of a specific type. /// Find the first source error of a specific type.
/// ///
/// See <https://github.com/seanmonstar/reqwest/issues/1602#issuecomment-1220996681> /// See <https://github.com/seanmonstar/reqwest/issues/1602#issuecomment-1220996681>
@ -1003,15 +1052,6 @@ fn find_source<E: Error + 'static>(orig: &dyn Error) -> Option<&E> {
None None
} }
/// Return all errors in the chain of a specific type.
///
/// This handles cases such as nested `io::Error`s.
///
/// See <https://github.com/seanmonstar/reqwest/issues/1602#issuecomment-1220996681>
fn find_sources<E: Error + 'static>(orig: &dyn Error) -> impl Iterator<Item = &E> {
iter::successors(find_source::<E>(orig), |&err| find_source(err))
}
// TODO(konsti): Remove once we find a native home for `retries_from_env` // TODO(konsti): Remove once we find a native home for `retries_from_env`
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum RetryParsingError { pub enum RetryParsingError {

View file

@ -14,7 +14,7 @@ use uv_fs::write_atomic;
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use crate::BaseClient; use crate::BaseClient;
use crate::base_client::is_extended_transient_error; use crate::base_client::is_transient_network_error;
use crate::{ use crate::{
Error, ErrorKind, Error, ErrorKind,
httpcache::{AfterResponse, BeforeRequest, CachePolicy, CachePolicyBuilder}, httpcache::{AfterResponse, BeforeRequest, CachePolicy, CachePolicyBuilder},
@ -141,7 +141,7 @@ impl<CallbackError: std::error::Error + 'static> CachedClientError<CallbackError
} }
} }
fn error(&self) -> &dyn std::error::Error { fn error(&self) -> &(dyn std::error::Error + 'static) {
match self { match self {
Self::Client { err, .. } => err, Self::Client { err, .. } => err,
Self::Callback { err, .. } => err, Self::Callback { err, .. } => err,
@ -452,7 +452,8 @@ impl CachedClient {
.await .await
} }
#[instrument(name="read_and_parse_cache", skip_all, fields(file = %cache_entry.path().display()))] #[instrument(name = "read_and_parse_cache", skip_all, fields(file = %cache_entry.path().display()
))]
async fn read_cache(cache_entry: &CacheEntry) -> Option<DataWithCachePolicy> { async fn read_cache(cache_entry: &CacheEntry) -> Option<DataWithCachePolicy> {
match DataWithCachePolicy::from_path_async(cache_entry.path()).await { match DataWithCachePolicy::from_path_async(cache_entry.path()).await {
Ok(data) => Some(data), Ok(data) => Some(data),
@ -680,7 +681,7 @@ impl CachedClient {
if result if result
.as_ref() .as_ref()
.is_err_and(|err| is_extended_transient_error(err.error())) .is_err_and(|err| is_transient_network_error(err.error()))
{ {
// If middleware already retried, consider that in our retry budget // If middleware already retried, consider that in our retry budget
let total_retries = past_retries + middleware_retries; let total_retries = past_retries + middleware_retries;
@ -739,7 +740,7 @@ impl CachedClient {
if result if result
.as_ref() .as_ref()
.err() .err()
.is_some_and(|err| is_extended_transient_error(err.error())) .is_some_and(|err| is_transient_network_error(err.error()))
{ {
let total_retries = past_retries + middleware_retries; let total_retries = past_retries + middleware_retries;
let retry_decision = retry_policy.should_retry(start_time, total_retries); let retry_decision = retry_policy.should_retry(start_time, total_retries);

View file

@ -1,7 +1,7 @@
pub use base_client::{ pub use base_client::{
AuthIntegration, BaseClient, BaseClientBuilder, DEFAULT_RETRIES, ExtraMiddleware, AuthIntegration, BaseClient, BaseClientBuilder, DEFAULT_RETRIES, ExtraMiddleware,
RedirectClientWithMiddleware, RequestBuilder, RetryParsingError, UvRetryableStrategy, RedirectClientWithMiddleware, RequestBuilder, RetryParsingError, UvRetryableStrategy,
is_extended_transient_error, retries_from_env, is_transient_network_error, retries_from_env,
}; };
pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy}; pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
pub use error::{Error, ErrorKind, WrappedReqwestError}; pub use error::{Error, ErrorKind, WrappedReqwestError};

View file

@ -12,6 +12,7 @@ use futures::TryStreamExt;
use itertools::Itertools; use itertools::Itertools;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use reqwest_retry::policies::ExponentialBackoff;
use reqwest_retry::{RetryError, RetryPolicy}; use reqwest_retry::{RetryError, RetryPolicy};
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
@ -21,7 +22,7 @@ use tokio_util::either::Either;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use url::Url; use url::Url;
use uv_client::{BaseClient, WrappedReqwestError, is_extended_transient_error}; use uv_client::{BaseClient, WrappedReqwestError, is_transient_network_error};
use uv_distribution_filename::{ExtensionError, SourceDistExtension}; use uv_distribution_filename::{ExtensionError, SourceDistExtension};
use uv_extract::hash::Hasher; use uv_extract::hash::Hasher;
use uv_fs::{Simplified, rename_with_retry}; use uv_fs::{Simplified, rename_with_retry};
@ -930,6 +931,7 @@ impl ManagedPythonDownload {
pub async fn fetch_with_retry( pub async fn fetch_with_retry(
&self, &self,
client: &BaseClient, client: &BaseClient,
retry_policy: &ExponentialBackoff,
installation_dir: &Path, installation_dir: &Path,
scratch_dir: &Path, scratch_dir: &Path,
reinstall: bool, reinstall: bool,
@ -940,7 +942,6 @@ impl ManagedPythonDownload {
let mut total_attempts = 0; let mut total_attempts = 0;
let mut retried_here = false; let mut retried_here = false;
let start_time = SystemTime::now(); let start_time = SystemTime::now();
let retry_policy = client.retry_policy();
loop { loop {
let result = self let result = self
.fetch( .fetch(
@ -961,7 +962,7 @@ impl ManagedPythonDownload {
total_attempts += err.attempts(); total_attempts += err.attempts();
// We currently interpret e.g. "3 retries" to mean we should make 4 attempts. // We currently interpret e.g. "3 retries" to mean we should make 4 attempts.
let n_past_retries = total_attempts - 1; let n_past_retries = total_attempts - 1;
if is_extended_transient_error(&err) { if is_transient_network_error(&err) {
let retry_decision = retry_policy.should_retry(start_time, n_past_retries); let retry_decision = retry_policy.should_retry(start_time, n_past_retries);
if let reqwest_retry::RetryDecision::Retry { execute_after } = if let reqwest_retry::RetryDecision::Retry { execute_after } =
retry_decision retry_decision

View file

@ -5,10 +5,11 @@ use std::str::FromStr;
use indexmap::IndexMap; use indexmap::IndexMap;
use ref_cast::RefCast; use ref_cast::RefCast;
use reqwest_retry::policies::ExponentialBackoff;
use tracing::{debug, info}; use tracing::{debug, info};
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::BaseClientBuilder; use uv_client::{BaseClientBuilder, retries_from_env};
use uv_pep440::{Prerelease, Version}; use uv_pep440::{Prerelease, Version};
use uv_platform::{Arch, Libc, Os, Platform}; use uv_platform::{Arch, Libc, Os, Platform};
use uv_preview::Preview; use uv_preview::Preview;
@ -228,12 +229,17 @@ impl PythonInstallation {
let scratch_dir = installations.scratch(); let scratch_dir = installations.scratch();
let _lock = installations.lock().await?; let _lock = installations.lock().await?;
let client = client_builder.build(); // Python downloads are performing their own retries to catch stream errors, disable the
// default retries to avoid the middleware from performing uncontrolled retries.
let retry_policy =
ExponentialBackoff::builder().build_with_max_retries(retries_from_env()?);
let client = client_builder.clone().retries(0).build();
info!("Fetching requested Python..."); info!("Fetching requested Python...");
let result = download let result = download
.fetch_with_retry( .fetch_with_retry(
&client, &client,
&retry_policy,
installations_dir, installations_dir,
&scratch_dir, &scratch_dir,
false, false,

View file

@ -99,6 +99,9 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
InvalidEnvironment(#[from] environment::InvalidEnvironment), InvalidEnvironment(#[from] environment::InvalidEnvironment),
#[error(transparent)]
RetryParsing(#[from] uv_client::RetryParsingError),
} }
impl Error { impl Error {

View file

@ -88,6 +88,7 @@ owo-colors = { workspace = true }
petgraph = { workspace = true } petgraph = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
reqwest-retry = { workspace = true }
rkyv = { workspace = true } rkyv = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View file

@ -2,11 +2,12 @@ use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest_retry::policies::ExponentialBackoff;
use tokio::process::Command; use tokio::process::Command;
use uv_bin_install::{Binary, bin_install}; use uv_bin_install::{Binary, bin_install};
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::BaseClientBuilder; use uv_client::{BaseClientBuilder, retries_from_env};
use uv_pep440::Version; use uv_pep440::Version;
use uv_preview::{Preview, PreviewFeatures}; use uv_preview::{Preview, PreviewFeatures};
use uv_warnings::warn_user; use uv_warnings::warn_user;
@ -45,13 +46,23 @@ pub(crate) async fn format(
// Parse version if provided // Parse version if provided
let version = version.as_deref().map(Version::from_str).transpose()?; let version = version.as_deref().map(Version::from_str).transpose()?;
let client = client_builder.build(); // Python downloads are performing their own retries to catch stream errors, disable the
// default retries to avoid the middleware from performing uncontrolled retries.
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retries_from_env()?);
let client = client_builder.retries(0).build();
// Get the path to Ruff, downloading it if necessary // Get the path to Ruff, downloading it if necessary
let reporter = BinaryDownloadReporter::single(printer); let reporter = BinaryDownloadReporter::single(printer);
let default_version = Binary::Ruff.default_version(); let default_version = Binary::Ruff.default_version();
let version = version.as_ref().unwrap_or(&default_version); let version = version.as_ref().unwrap_or(&default_version);
let ruff_path = bin_install(Binary::Ruff, version, &client, &cache, &reporter) let ruff_path = bin_install(
Binary::Ruff,
version,
&client,
&retry_policy,
&cache,
&reporter,
)
.await .await
.with_context(|| format!("Failed to install ruff {version}"))?; .with_context(|| format!("Failed to install ruff {version}"))?;

View file

@ -11,9 +11,11 @@ use futures::stream::FuturesUnordered;
use indexmap::IndexSet; use indexmap::IndexSet;
use itertools::{Either, Itertools}; use itertools::{Either, Itertools};
use owo_colors::{AnsiColors, OwoColorize}; use owo_colors::{AnsiColors, OwoColorize};
use reqwest_retry::policies::ExponentialBackoff;
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use tracing::{debug, trace}; use tracing::{debug, trace};
use uv_client::BaseClientBuilder;
use uv_client::{BaseClientBuilder, retries_from_env};
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_platform::{Arch, Libc}; use uv_platform::{Arch, Libc};
use uv_preview::{Preview, PreviewFeatures}; use uv_preview::{Preview, PreviewFeatures};
@ -401,8 +403,11 @@ pub(crate) async fn install(
.unique_by(|download| download.key()) .unique_by(|download| download.key())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Download and unpack the Python versions concurrently // Python downloads are performing their own retries to catch stream errors, disable the
let client = client_builder.build(); // default retries to avoid the middleware from performing uncontrolled retries.
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retries_from_env()?);
let client = client_builder.retries(0).build();
let reporter = PythonDownloadReporter::new(printer, downloads.len() as u64); let reporter = PythonDownloadReporter::new(printer, downloads.len() as u64);
let mut tasks = FuturesUnordered::new(); let mut tasks = FuturesUnordered::new();
@ -413,6 +418,7 @@ pub(crate) async fn install(
download download
.fetch_with_retry( .fetch_with_retry(
&client, &client,
&retry_policy,
installations_dir, installations_dir,
&scratch_dir, &scratch_dir,
reinstall, reinstall,

View file

@ -293,8 +293,8 @@ async fn python_install_io_error() {
----- stderr ----- ----- stderr -----
error: Failed to install cpython-3.10.0-macos-aarch64-none error: Failed to install cpython-3.10.0-macos-aarch64-none
Caused by: Failed to download [SERVER]/astral-sh/python-build-standalone/releases/download/20211017/cpython-3.10.0-aarch64-apple-darwin-pgo%2Blto-20211017T1616.tar.zst
Caused by: Request failed after 3 retries Caused by: Request failed after 3 retries
Caused by: Failed to download [SERVER]/astral-sh/python-build-standalone/releases/download/20211017/cpython-3.10.0-aarch64-apple-darwin-pgo%2Blto-20211017T1616.tar.zst
Caused by: error sending request for url ([SERVER]/astral-sh/python-build-standalone/releases/download/20211017/cpython-3.10.0-aarch64-apple-darwin-pgo%2Blto-20211017T1616.tar.zst) Caused by: error sending request for url ([SERVER]/astral-sh/python-build-standalone/releases/download/20211017/cpython-3.10.0-aarch64-apple-darwin-pgo%2Blto-20211017T1616.tar.zst)
Caused by: client error (SendRequest) Caused by: client error (SendRequest)
Caused by: connection closed before message completed Caused by: connection closed before message completed