Skip to content

Commit 81d4e19

Browse files
committed
add retries to OCI network hiccups
Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com> Assisted-by: claude-opus-4.6
1 parent 60e5a9b commit 81d4e19

14 files changed

Lines changed: 734 additions & 229 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ openssl = { version = "0.10", features = ["vendored"] }
2323
xz2 = "0.1"
2424

2525
[dev-dependencies]
26+
http-body = "1.0.1"
2627
wiremock = "0.6"
2728
tempfile = "3"
2829
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }

src/fls/decompress.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,43 @@ pub(crate) async fn start_decompressor_process(
3535
) -> Result<(Child, &'static str), Box<dyn std::error::Error>> {
3636
let cmd = get_decompressor_command(url);
3737

38-
// Check if binary is available before attempting to spawn
3938
check_binary_available(cmd)?;
39+
eprintln!("Using decompressor: {}", cmd);
40+
spawn_decompressor(cmd)
41+
}
4042

41-
println!("Using decompressor: {}", cmd);
43+
/// Maps a Compression enum to the corresponding decompressor command
44+
pub(crate) fn decompressor_for_compression(
45+
compression: crate::fls::compression::Compression,
46+
) -> &'static str {
47+
use crate::fls::compression::Compression;
48+
match compression {
49+
Compression::Gzip => "zcat",
50+
Compression::Xz => "xzcat",
51+
Compression::Zstd => "zstdcat",
52+
Compression::None => "cat",
53+
}
54+
}
4255

56+
/// Starts a decompressor process based on detected compression type
57+
pub(crate) fn start_decompressor_for_compression(
58+
compression: crate::fls::compression::Compression,
59+
) -> Result<(Child, &'static str), Box<dyn std::error::Error>> {
60+
let cmd = decompressor_for_compression(compression);
61+
check_binary_available(cmd)?;
62+
eprintln!("Using decompressor: {}", cmd);
63+
spawn_decompressor(cmd)
64+
}
65+
66+
/// Spawns a decompressor subprocess with piped stdin/stdout/stderr
67+
fn spawn_decompressor(
68+
cmd: &'static str,
69+
) -> Result<(Child, &'static str), Box<dyn std::error::Error>> {
4370
let process = Command::new(cmd)
4471
.stdin(std::process::Stdio::piped())
4572
.stdout(std::process::Stdio::piped())
4673
.stderr(std::process::Stdio::piped())
4774
.spawn()?;
48-
4975
Ok((process, cmd))
5076
}
5177

src/fls/download_error.rs

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,13 @@ impl DownloadError {
7676
Self::from_http_status(status)
7777
}
7878

79-
/// Create a DownloadError from a reqwest::Error
79+
/// Create a DownloadError from a reqwest::Error (owned)
8080
pub fn from_reqwest(error: reqwest::Error) -> Self {
81+
Self::from_reqwest_ref(&error)
82+
}
83+
84+
/// Create a DownloadError from a reqwest::Error reference
85+
pub fn from_reqwest_ref(error: &reqwest::Error) -> Self {
8186
if error.is_status() {
8287
if let Some(status) = error.status() {
8388
return Self::from_http_status(status);
@@ -93,22 +98,17 @@ impl DownloadError {
9398
let error_str = error.to_string();
9499

95100
// Check error source chain for TLS/SSL/Certificate errors
96-
// We use a hybrid approach: check both type names and error messages
97-
// - Type names catch concrete types before trait object erasure
98-
// - Error messages catch issues when types are erased to dyn Error
99-
let mut current_error: Option<&dyn std::error::Error> = Some(&error);
101+
let mut current_error: Option<&dyn std::error::Error> = Some(error);
100102
let mut is_tls_error = false;
101103

102104
while let Some(err) = current_error {
103105
let error_msg = err.to_string().to_lowercase();
104106

105-
// Check error message for TLS-related keywords
106-
// This works even when types are erased to trait objects
107107
let message_indicates_tls = error_msg.contains("certificate")
108108
|| error_msg.contains("tls")
109109
|| error_msg.contains("ssl")
110-
|| error_msg.contains("trust setting") // macOS Security Framework
111-
|| error_msg.contains("trust policy"); // macOS Security Framework
110+
|| error_msg.contains("trust setting")
111+
|| error_msg.contains("trust policy");
112112

113113
if message_indicates_tls {
114114
is_tls_error = true;
@@ -122,14 +122,12 @@ impl DownloadError {
122122
return DownloadError::TlsError(error_str);
123123
}
124124

125-
// Check for DNS errors
126125
if error_str.contains("dns") || error_str.contains("failed to lookup address") {
127126
return DownloadError::DnsError(error_str);
128127
}
129128
return DownloadError::ConnectionError(error_str);
130129
}
131130

132-
// Fallback for other error types
133131
DownloadError::Other(error.to_string())
134132
}
135133

@@ -231,6 +229,42 @@ impl fmt::Display for DownloadError {
231229

232230
impl std::error::Error for DownloadError {}
233231

232+
/// Shared retry handler for download errors.
233+
///
234+
/// Returns `Some(Duration)` with the delay to wait before retrying, or `None` if
235+
/// the error is non-retryable or max retries have been exceeded.
236+
pub fn handle_download_retry(
237+
error: &DownloadError,
238+
retry_count: &mut usize,
239+
max_retries: usize,
240+
default_retry_delay_secs: u64,
241+
) -> Option<Duration> {
242+
if !error.is_retryable() {
243+
eprintln!(
244+
"\nDownload failed with non-retryable error: {}",
245+
error.format_error()
246+
);
247+
return None;
248+
}
249+
if *retry_count >= max_retries {
250+
eprintln!("\nMax retries ({}) reached, giving up", max_retries);
251+
eprintln!("Last error: {}", error.format_error());
252+
return None;
253+
}
254+
let retry_delay = error
255+
.suggested_retry_delay()
256+
.unwrap_or_else(|| Duration::from_secs(default_retry_delay_secs));
257+
eprintln!("\nDownload failed: {}", error.format_error());
258+
eprintln!(
259+
"Retrying in {} seconds... (attempt {}/{})",
260+
retry_delay.as_secs(),
261+
*retry_count + 1,
262+
max_retries
263+
);
264+
*retry_count += 1;
265+
Some(retry_delay)
266+
}
267+
234268
#[cfg(test)]
235269
mod tests {
236270
use super::*;

src/fls/fastboot.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ fn build_oci_options(options: &FastbootOptions) -> crate::fls::options::OciOptio
8686
username: options.username.clone(),
8787
password: options.password.clone(),
8888
file_pattern: None,
89+
max_retries: crate::fls::options::DEFAULT_MAX_RETRIES,
90+
retry_delay_secs: crate::fls::options::DEFAULT_RETRY_DELAY_SECS,
8991
}
9092
}
9193

src/fls/from_url.rs

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -49,44 +49,7 @@ async fn get_decompressor_error(
4949
}
5050
}
5151

52-
/// Handles retry logic for download errors.
53-
///
54-
/// Returns `Some(Duration)` with the delay to wait before retrying, or `None` if
55-
/// the error is non-retryable or max retries have been exceeded.
56-
fn handle_retry_error(
57-
error: &DownloadError,
58-
retry_count: &mut usize,
59-
max_retries: usize,
60-
default_retry_delay_secs: u64,
61-
) -> Option<Duration> {
62-
if !error.is_retryable() {
63-
eprintln!(
64-
"\nDownload failed with non-retryable error: {}",
65-
error.format_error()
66-
);
67-
return None;
68-
}
69-
70-
if *retry_count >= max_retries {
71-
eprintln!("\nMax retries ({}) reached, giving up", max_retries);
72-
eprintln!("Last error: {}", error.format_error());
73-
return None;
74-
}
75-
76-
let retry_delay = error
77-
.suggested_retry_delay()
78-
.unwrap_or_else(|| Duration::from_secs(default_retry_delay_secs));
79-
80-
eprintln!("\nDownload failed: {}", error.format_error());
81-
eprintln!(
82-
"Retrying in {} seconds... (attempt {}/{})",
83-
retry_delay.as_secs(),
84-
*retry_count + 1,
85-
max_retries
86-
);
87-
*retry_count += 1;
88-
Some(retry_delay)
89-
}
52+
use crate::fls::download_error::handle_download_retry;
9053

9154
/// Execute a sequence of write commands on the block writer
9255
async fn execute_write_commands(
@@ -407,7 +370,7 @@ pub async fn flash_from_url(
407370
match start_download(url, &client, resume_from, &options.headers, debug).await {
408371
Ok(r) => r,
409372
Err(e) => {
410-
match handle_retry_error(
373+
match handle_download_retry(
411374
&e,
412375
&mut retry_count,
413376
options.max_retries,
@@ -551,7 +514,7 @@ pub async fn flash_from_url(
551514

552515
if let Some(e) = connection_error {
553516
eprintln!("\nConnection interrupted: {}", e.format_error());
554-
match handle_retry_error(
517+
match handle_download_retry(
555518
&e,
556519
&mut retry_count,
557520
options.max_retries,

src/fls/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub use from_url::flash_from_url;
2424
pub use oci::flash_from_oci;
2525
pub use options::{
2626
BlockFlashOptions, FastbootOptions, FlashOptions, HttpClientOptions, OciOptions,
27+
DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY_SECS,
2728
};
2829

2930
#[cfg(test)]

0 commit comments

Comments
 (0)