Skip to content

Commit bdf5359

Browse files
committed
feat: add retry logic and extended timeouts for large file downloads
- Add automatic retry mechanism with exponential backoff (3 retries max) - Implement automatic resume on retry by re-checking file size - Extend connection timeout to 10 minutes for large file downloads - Add connection pooling and TCP keepalive settings - Refactor download_file_content to handle transient network errors - Simplify function signature by removing explicit file_size parameter Addresses issues: - #76: Large files downloading with incorrect size/hash errors - #89: Connection errors during download ('error decoding response body') Changes from PR #86 by @ApfelTeeSaft
1 parent da6e2a1 commit bdf5359

File tree

2 files changed

+198
-85
lines changed

2 files changed

+198
-85
lines changed

src/downloader.rs

Lines changed: 186 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const BUFFER_SIZE: usize = 8192;
2020
/// File size threshold for showing hash progress bar (2MB)
2121
const LARGE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024;
2222

23+
/// Maximum number of retry attempts for failed downloads
24+
const MAX_RETRIES: u32 = 3;
25+
26+
/// Initial delay between retries in milliseconds (doubles with each retry)
27+
const INITIAL_RETRY_DELAY_MS: u64 = 1000;
28+
2329
/// Sets up signal handling for graceful shutdown on Ctrl+C
2430
///
2531
/// Returns an Arc<AtomicBool> that can be checked to see if the process
@@ -153,101 +159,202 @@ fn prepare_file_for_download(file_path: &str) -> Result<File> {
153159
Ok(file)
154160
}
155161

156-
/// Download file content with progress reporting
162+
/// Download file content with progress reporting and automatic retry on failure
157163
async fn download_file_content(
158164
client: &Client,
159165
url: &str,
160-
file_size: u64,
161166
file: &mut File,
162167
running: &Arc<AtomicBool>,
163-
is_resuming: bool,
164168
) -> Result<u64> {
165-
let download_action = if is_resuming {
166-
format!("{} {} ", "╰╼".cyan().dimmed(), "Resuming".white())
167-
} else {
168-
format!("{} {} ", "╰╼".cyan().dimmed(), "Downloading".white())
169-
};
170-
171-
let mut headers = HeaderMap::new();
172-
if file_size > 0 {
173-
// Use IaGetError::Network for header parsing errors
174-
headers.insert(
175-
reqwest::header::RANGE,
176-
HeaderValue::from_str(&format!("bytes={}-", file_size))
177-
.map_err(|e| IaGetError::Network(format!("Invalid range header value: {}", e)))?,
178-
);
179-
}
169+
let mut retry_count = 0;
180170

181-
let mut response = if file_size > 0 && is_resuming {
182-
// Ensure headers are only used for resume
183-
client.get(url).headers(headers).send().await?
184-
} else {
185-
client.get(url).send().await?
186-
};
171+
loop {
172+
// Re-check file size at start of each attempt (in case of retry)
173+
let current_file_size = file.metadata()?.len();
174+
let download_action = if current_file_size > 0 {
175+
format!("{} {} ", "╰╼".cyan().dimmed(), "Resuming".white())
176+
} else {
177+
format!("{} {} ", "╰╼".cyan().dimmed(), "Downloading".white())
178+
};
179+
180+
let mut headers = HeaderMap::new();
181+
if current_file_size > 0 {
182+
// Use IaGetError::Network for header parsing errors
183+
headers.insert(
184+
reqwest::header::RANGE,
185+
HeaderValue::from_str(&format!("bytes={}-", current_file_size)).map_err(|e| {
186+
IaGetError::Network(format!("Invalid range header value: {}", e))
187+
})?,
188+
);
189+
}
187190

188-
let content_length = response.content_length().unwrap_or(0);
189-
let total_expected_size = if is_resuming {
190-
content_length + file_size
191-
} else {
192-
content_length
193-
};
191+
// Try to send the request with retry logic
192+
let mut response = match if current_file_size > 0 {
193+
client.get(url).headers(headers).send().await
194+
} else {
195+
client.get(url).send().await
196+
} {
197+
Ok(resp) => resp,
198+
Err(e) => {
199+
// Request failed before we even got a response
200+
retry_count += 1;
201+
202+
if retry_count > MAX_RETRIES {
203+
println!(
204+
"{} {} {} Maximum retries ({}) exceeded",
205+
"├╼".cyan().dimmed(),
206+
"Failed".red().bold(),
207+
"✘".red().bold(),
208+
MAX_RETRIES
209+
);
210+
return Err(e.into());
211+
}
212+
213+
let delay = INITIAL_RETRY_DELAY_MS * 2u64.pow(retry_count - 1);
214+
println!(
215+
"{} {} {} Connection error (attempt {}/{}): {}",
216+
"├╼".cyan().dimmed(),
217+
"Retry".yellow().bold(),
218+
"⟳".yellow().bold(),
219+
retry_count,
220+
MAX_RETRIES,
221+
e
222+
);
223+
println!(
224+
"{} {} Waiting {:.1}s before retry...",
225+
"├╼".cyan().dimmed(),
226+
"Wait".white(),
227+
delay as f64 / 1000.0
228+
);
194229

195-
let pb = create_progress_bar(
196-
total_expected_size,
197-
&download_action,
198-
Some("green/green"), // Color for download bar
199-
true,
200-
);
230+
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
201231

202-
// Set initial progress to current file size for resumed downloads
203-
pb.set_position(file_size);
232+
// Ensure file is ready for next attempt
233+
file.flush()?;
234+
file.seek(SeekFrom::End(0))?;
204235

205-
let start_time = std::time::Instant::now();
206-
let mut total_bytes: u64 = file_size;
207-
let mut downloaded_bytes: u64 = 0;
236+
continue; // Retry from the top of the loop
237+
}
238+
};
239+
240+
let content_length = response.content_length().unwrap_or(0);
241+
let total_expected_size = if current_file_size > 0 {
242+
content_length + current_file_size
243+
} else {
244+
content_length
245+
};
246+
247+
let pb = create_progress_bar(
248+
total_expected_size,
249+
&download_action,
250+
Some("green/green"),
251+
true,
252+
);
208253

209-
while let Some(chunk) = response.chunk().await? {
210-
if !running.load(Ordering::SeqCst) {
211-
pb.finish_and_clear();
212-
return Err(std::io::Error::new(
213-
std::io::ErrorKind::Interrupted,
214-
"Download interrupted during file transfer",
215-
)
216-
.into());
254+
// Set initial progress to current file size for resumed downloads
255+
pb.set_position(current_file_size);
256+
257+
let start_time = std::time::Instant::now();
258+
let mut total_bytes: u64 = current_file_size;
259+
let mut downloaded_bytes: u64 = 0;
260+
261+
// Attempt the download
262+
let download_result: Result<()> = async {
263+
while let Some(chunk_result) = response.chunk().await.transpose() {
264+
if !running.load(Ordering::SeqCst) {
265+
pb.finish_and_clear();
266+
return Err(std::io::Error::new(
267+
std::io::ErrorKind::Interrupted,
268+
"Download interrupted during file transfer",
269+
)
270+
.into());
271+
}
272+
273+
let chunk = chunk_result?;
274+
file.write_all(&chunk)?;
275+
downloaded_bytes += chunk.len() as u64;
276+
total_bytes += chunk.len() as u64;
277+
pb.set_position(total_bytes);
278+
}
279+
Ok(())
217280
}
281+
.await;
218282

219-
file.write_all(&chunk)?;
220-
downloaded_bytes += chunk.len() as u64;
221-
total_bytes += chunk.len() as u64;
222-
pb.set_position(total_bytes);
223-
}
283+
match download_result {
284+
Ok(_) => {
285+
// Ensure data is written to disk
286+
file.flush()?;
224287

225-
// Ensure data is written to disk
226-
file.flush()?;
288+
let elapsed = start_time.elapsed();
289+
let elapsed_secs = elapsed.as_secs_f64();
290+
let transfer_rate_val = if elapsed_secs > 0.0 {
291+
downloaded_bytes as f64 / elapsed_secs
292+
} else {
293+
0.0
294+
};
227295

228-
let elapsed = start_time.elapsed();
229-
let elapsed_secs = elapsed.as_secs_f64();
230-
let transfer_rate_val = if elapsed_secs > 0.0 {
231-
downloaded_bytes as f64 / elapsed_secs
232-
} else {
233-
0.0
234-
};
296+
let (rate, unit) = format_transfer_rate(transfer_rate_val);
297+
298+
pb.finish_and_clear();
299+
println!(
300+
"{} {} {} {} in {} ({:.2} {}/s)",
301+
"├╼".cyan().dimmed(),
302+
"Downloaded".white(),
303+
"↓".green().bold(),
304+
format_size(downloaded_bytes).bold(),
305+
format_duration(elapsed).bold(),
306+
rate,
307+
unit
308+
);
235309

236-
let (rate, unit) = format_transfer_rate(transfer_rate_val);
237-
238-
pb.finish_and_clear();
239-
println!(
240-
"{} {} {} {} in {} ({:.2} {}/s)",
241-
"├╼".cyan().dimmed(),
242-
"Downloaded".white(),
243-
"↓".green().bold(),
244-
format_size(downloaded_bytes).bold(),
245-
format_duration(elapsed).bold(),
246-
rate,
247-
unit
248-
);
249-
250-
Ok(total_bytes)
310+
return Ok(total_bytes);
311+
}
312+
Err(e) => {
313+
pb.finish_and_clear();
314+
315+
// Check if this is a user interruption
316+
if e.to_string().contains("interrupted") {
317+
return Err(e);
318+
}
319+
320+
retry_count += 1;
321+
322+
if retry_count > MAX_RETRIES {
323+
println!(
324+
"{} {} {} Maximum retries ({}) exceeded",
325+
"├╼".cyan().dimmed(),
326+
"Failed".red().bold(),
327+
"✘".red().bold(),
328+
MAX_RETRIES
329+
);
330+
return Err(e);
331+
}
332+
333+
let delay = INITIAL_RETRY_DELAY_MS * 2u64.pow(retry_count - 1);
334+
println!(
335+
"{} {} {} Download error (attempt {}/{}): {}",
336+
"├╼".cyan().dimmed(),
337+
"Retry".yellow().bold(),
338+
"⟳".yellow().bold(),
339+
retry_count,
340+
MAX_RETRIES,
341+
e
342+
);
343+
println!(
344+
"{} {} Waiting {:.1}s before retry...",
345+
"├╼".cyan().dimmed(),
346+
"Wait".white(),
347+
delay as f64 / 1000.0
348+
);
349+
350+
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
351+
352+
// Ensure file is flushed and ready for next attempt
353+
file.flush()?;
354+
file.seek(SeekFrom::End(0))?;
355+
}
356+
}
357+
}
251358
}
252359

253360
/// Verify a downloaded file's hash against an expected value
@@ -349,9 +456,7 @@ where
349456

350457
let mut file = prepare_file_for_download(&file_path)?;
351458

352-
let file_size = file.metadata()?.len();
353-
let is_resuming = file_size > 0;
354-
download_file_content(client, &url, file_size, &mut file, &running, is_resuming).await?;
459+
download_file_content(client, &url, &mut file, &running).await?;
355460
verify_downloaded_file(&file_path, expected_md5.as_deref(), &running)?;
356461
}
357462

src/main.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88
use clap::Parser;
99
use colored::*;
1010
use ia_get::archive_metadata::{parse_xml_files, XmlFiles};
11-
use ia_get::constants::{HTTP_TIMEOUT, USER_AGENT};
11+
use ia_get::constants::USER_AGENT;
1212
use ia_get::downloader;
1313
use ia_get::utils::{create_spinner, validate_archive_url};
1414
use ia_get::Result;
1515
use indicatif::ProgressStyle;
1616
use reqwest::Client; // Add this line
1717

18+
/// Extended timeout for large file downloads (10 minutes for connection, no read timeout)
19+
const CONNECTION_TIMEOUT_SECS: u64 = 600;
20+
1821
/// Checks if a URL is accessible by sending a HEAD request
1922
async fn is_url_accessible(url: &str, client: &Client) -> Result<()> {
2023
let response = client
2124
.head(url)
22-
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT))
25+
.timeout(std::time::Duration::from_secs(60))
2326
.send()
2427
.await?;
2528

@@ -127,10 +130,15 @@ struct Cli {
127130
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
128131
let cli = Cli::parse();
129132

130-
// Create a single client instance for all requests
133+
// Create a client with extended timeouts for large file downloads
134+
// Connection timeout is set high, but no read timeout since large files
135+
// may take a long time to transfer
131136
let client = Client::builder()
132137
.user_agent(USER_AGENT)
133-
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT))
138+
.connect_timeout(std::time::Duration::from_secs(CONNECTION_TIMEOUT_SECS))
139+
.pool_idle_timeout(std::time::Duration::from_secs(90))
140+
.pool_max_idle_per_host(1)
141+
.tcp_keepalive(std::time::Duration::from_secs(60))
134142
.build()?;
135143

136144
// Start a single spinner for the entire initialization process

0 commit comments

Comments
 (0)