use std::path::Path;
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::utils::{BraheError, atomic_write};
const MAX_DOWNLOAD_ATTEMPTS: u32 = 4;
const BACKOFF_BASE_DELAY: Duration = Duration::from_millis(500);
const BACKOFF_MAX_DELAY: Duration = Duration::from_secs(30);
fn is_retryable_error(e: &ureq::Error) -> bool {
matches!(
e,
ureq::Error::StatusCode(429 | 500 | 502 | 503 | 504)
| ureq::Error::Io(_)
| ureq::Error::Timeout(_)
| ureq::Error::HostNotFound
| ureq::Error::ConnectionFailed
)
}
fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BACKOFF_BASE_DELAY.as_millis() as u64;
let max_ms = BACKOFF_MAX_DELAY.as_millis() as u64;
let window_ms = base_ms
.saturating_mul(1u64 << (attempt - 1).min(32))
.min(max_ms);
let entropy = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let jittered_ms = if window_ms == 0 {
0
} else {
entropy % (window_ms + 1)
};
Duration::from_millis(jittered_ms)
}
pub(crate) fn download_to_file(
url: &str,
description: &str,
filepath: &Path,
) -> Result<(), BraheError> {
let mut attempt: u32 = 0;
let body = loop {
attempt += 1;
match ureq::get(url)
.call()
.and_then(|mut response| response.body_mut().read_to_string())
{
Ok(body) => break body,
Err(e) => {
if attempt < MAX_DOWNLOAD_ATTEMPTS && is_retryable_error(&e) {
sleep(backoff_delay(attempt));
continue;
}
return Err(BraheError::IoError(format!(
"{} download from {} failed after {} attempt(s): {}",
description, url, attempt, e
)));
}
}
};
atomic_write(filepath, body.as_bytes())?;
Ok(())
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn test_is_retryable_error() {
use std::io;
assert!(is_retryable_error(&ureq::Error::ConnectionFailed));
assert!(is_retryable_error(&ureq::Error::HostNotFound));
assert!(is_retryable_error(&ureq::Error::Io(io::Error::new(
io::ErrorKind::ConnectionRefused,
"Connection refused"
))));
assert!(is_retryable_error(&ureq::Error::StatusCode(503)));
assert!(is_retryable_error(&ureq::Error::StatusCode(429)));
assert!(!is_retryable_error(&ureq::Error::StatusCode(404)));
assert!(!is_retryable_error(&ureq::Error::StatusCode(400)));
}
#[test]
fn test_backoff_delay_bounds() {
for attempt in 1..=8u32 {
let window_ms = (BACKOFF_BASE_DELAY.as_millis() as u64)
.saturating_mul(1u64 << (attempt - 1).min(32))
.min(BACKOFF_MAX_DELAY.as_millis() as u64);
for _ in 0..100 {
let delay = backoff_delay(attempt);
assert!(delay <= Duration::from_millis(window_ms));
assert!(delay <= BACKOFF_MAX_DELAY);
}
}
}
}