use std::fs::OpenOptions;
#[cfg(feature = "net")]
use std::io::{Read, SeekFrom};
use std::io::{Seek, Write};
use std::path::Path;
use std::time::Duration;
#[derive(Debug)]
pub struct FetchError {
pub message: String,
pub retryable: bool,
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
pub fn status_is_retryable(code: u16) -> bool {
code == 429 || code >= 500
}
#[derive(Clone, Copy, Debug)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> RetryPolicy {
RetryPolicy {
max_attempts: 4,
base_delay: Duration::from_millis(500),
}
}
}
impl RetryPolicy {
pub fn no_delay() -> RetryPolicy {
RetryPolicy {
max_attempts: 4,
base_delay: Duration::ZERO,
}
}
fn delay(&self, attempt: u32) -> Duration {
self.base_delay * 2u32.saturating_pow(attempt.saturating_sub(1))
}
}
pub trait Sink: Write + Seek {}
impl<T: Write + Seek + ?Sized> Sink for T {}
#[derive(Clone, Copy, Debug)]
pub struct DownloadOutcome {
pub resumed: bool,
pub total: Option<u64>,
}
#[derive(Clone, Copy, Debug)]
pub struct DownloadStats {
pub attempts: u32,
}
pub trait Fetch {
fn get(&self, url: &str) -> Result<Vec<u8>, String>;
fn get_to(
&self,
url: &str,
range_from: u64,
sink: &mut dyn Sink,
on_progress: &mut dyn FnMut(u64, Option<u64>),
) -> Result<DownloadOutcome, FetchError>;
}
pub fn download_retrying(
fetch: &dyn Fetch,
url: &str,
dest: &Path,
policy: &RetryPolicy,
on_progress: &mut dyn FnMut(u64, Option<u64>),
) -> Result<DownloadStats, String> {
let _ = std::fs::remove_file(dest);
let mut attempt = 0u32;
loop {
attempt += 1;
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false) .open(dest)
.map_err(|e| format!("opening {}: {e}", dest.display()))?;
let have = file.metadata().map_err(|e| e.to_string())?.len();
match fetch.get_to(url, have, &mut file, on_progress) {
Ok(_) => {
let end = file.stream_position().map_err(|e| e.to_string())?;
file.set_len(end).map_err(|e| e.to_string())?;
return Ok(DownloadStats { attempts: attempt });
}
Err(e) if e.retryable && attempt < policy.max_attempts => {
drop(file); let have = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
let backoff = policy.delay(attempt);
eprintln!(
"[fetch] {url}: {} — resuming from {have} B (attempt {}/{}) in {:.1}s",
e.message,
attempt + 1,
policy.max_attempts,
backoff.as_secs_f64()
);
std::thread::sleep(backoff);
}
Err(e) => {
let _ = std::fs::remove_file(dest);
return Err(e.message);
}
}
}
}
#[cfg(feature = "net")]
pub struct NetworkFetch {
agent: ureq::Agent,
}
#[cfg(feature = "net")]
impl NetworkFetch {
pub fn new() -> NetworkFetch {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(30))
.build();
NetworkFetch { agent }
}
}
#[cfg(feature = "net")]
impl Default for NetworkFetch {
fn default() -> NetworkFetch {
NetworkFetch::new()
}
}
#[cfg(feature = "net")]
fn ureq_retryable(e: &ureq::Error) -> bool {
match e {
ureq::Error::Transport(_) => true,
ureq::Error::Status(code, _) => status_is_retryable(*code),
}
}
#[cfg(feature = "net")]
fn content_range_total(header: &str) -> Option<u64> {
header.rsplit('/').next()?.trim().parse::<u64>().ok()
}
#[cfg(feature = "net")]
impl Fetch for NetworkFetch {
fn get(&self, url: &str) -> Result<Vec<u8>, String> {
let resp = self
.agent
.get(url)
.call()
.map_err(|e| format!("GET {url}: {e}"))?;
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.map_err(|e| format!("reading {url}: {e}"))?;
Ok(buf)
}
fn get_to(
&self,
url: &str,
range_from: u64,
sink: &mut dyn Sink,
on_progress: &mut dyn FnMut(u64, Option<u64>),
) -> Result<DownloadOutcome, FetchError> {
let mut req = self.agent.get(url);
if range_from > 0 {
req = req.set("Range", &format!("bytes={range_from}-"));
}
let resp = req.call().map_err(|e| FetchError {
message: format!("GET {url}: {e}"),
retryable: ureq_retryable(&e),
})?;
let resumed = resp.status() == 206;
let total = if resumed {
resp.header("Content-Range").and_then(content_range_total)
} else {
resp.header("Content-Length")
.and_then(|s| s.parse::<u64>().ok())
};
let base = if resumed { range_from } else { 0 };
sink.seek(SeekFrom::Start(base)).map_err(|e| FetchError {
message: format!("seek {url}: {e}"),
retryable: false,
})?;
let mut reader = resp.into_reader();
let mut buf = [0u8; 64 * 1024];
let mut done = base;
on_progress(done, total);
loop {
let n = reader.read(&mut buf).map_err(|e| FetchError {
message: format!("reading {url}: {e}"),
retryable: true,
})?;
if n == 0 {
break;
}
sink.write_all(&buf[..n]).map_err(|e| FetchError {
message: format!("writing {url}: {e}"),
retryable: false, })?;
done += n as u64;
on_progress(done, total);
}
Ok(DownloadOutcome { resumed, total })
}
}