1use std::io::{Read, Write};
8use std::path::Path;
9use std::time::Duration;
10
11use thiserror::Error;
12
13const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15
16const STALL_TIMEOUT: Duration = Duration::from_secs(30);
22
23pub const API_TIMEOUT: Duration = Duration::from_secs(30);
25
26pub const DEFAULT_ATTEMPTS: u32 = 3;
28
29const BACKOFF_BASE: Duration = Duration::from_millis(500);
31
32#[derive(Debug, Error)]
33pub enum DownloadError {
34 #[error("http error: {0}")]
35 Http(#[from] reqwest::Error),
36 #[error("io error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("incomplete download: got {got} of {expected} bytes")]
39 Incomplete { got: u64, expected: u64 },
40 #[error("server returned {0}")]
41 Status(reqwest::StatusCode),
42 #[error("request could not be built: {0}")]
43 Request(String),
44}
45
46impl DownloadError {
47 pub fn is_retryable(&self) -> bool {
50 match self {
51 DownloadError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
52 DownloadError::Io(_) | DownloadError::Incomplete { .. } => true,
53 DownloadError::Status(s) => {
54 s.is_server_error() || *s == reqwest::StatusCode::TOO_MANY_REQUESTS
55 }
56 DownloadError::Request(_) => false,
57 }
58 }
59}
60
61pub fn download_client() -> reqwest::Result<reqwest::blocking::Client> {
64 reqwest::blocking::Client::builder()
65 .connect_timeout(CONNECT_TIMEOUT)
66 .timeout(STALL_TIMEOUT)
67 .build()
68}
69
70pub fn api_client() -> reqwest::Result<reqwest::blocking::Client> {
72 reqwest::blocking::Client::builder()
73 .connect_timeout(CONNECT_TIMEOUT)
74 .timeout(API_TIMEOUT)
75 .build()
76}
77
78pub fn download_with_retries(
88 dest: &Path,
89 attempts: u32,
90 request: impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
91 on_progress: impl Fn(u64, u64),
92) -> Result<u64, DownloadError> {
93 let attempts = attempts.max(1);
94 let mut last_err = None;
95
96 for attempt in 0..attempts {
97 if attempt > 0 {
98 let backoff = BACKOFF_BASE * 2u32.pow(attempt - 1);
99 log::warn!(
100 "download of {} failed ({}), retrying in {:?} ({}/{})",
101 dest.display(),
102 last_err
103 .as_ref()
104 .map(|e: &DownloadError| e.to_string())
105 .unwrap_or_default(),
106 backoff,
107 attempt + 1,
108 attempts
109 );
110 std::thread::sleep(backoff);
111 }
112
113 match attempt_download(dest, &request, &on_progress) {
114 Ok(bytes) => return Ok(bytes),
115 Err(e) if e.is_retryable() => last_err = Some(e),
116 Err(e) => return Err(e),
117 }
118 }
119
120 Err(last_err.expect("loop runs at least once and only continues on error"))
121}
122
123fn attempt_download(
124 dest: &Path,
125 request: &impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
126 on_progress: &impl Fn(u64, u64),
127) -> Result<u64, DownloadError> {
128 let resp = request()?.send()?;
129 let status = resp.status();
130 if !status.is_success() {
131 return Err(DownloadError::Status(status));
132 }
133 stream_to_file(resp, dest, on_progress)
134}
135
136fn stream_to_file(
140 mut resp: reqwest::blocking::Response,
141 dest: &Path,
142 on_progress: &impl Fn(u64, u64),
143) -> Result<u64, DownloadError> {
144 let total = resp.content_length().unwrap_or(0);
145
146 if let Some(parent) = dest.parent() {
147 std::fs::create_dir_all(parent)?;
148 }
149
150 let tmp = part_path(dest);
151 let mut file = std::fs::File::create(&tmp)?;
152 let mut downloaded: u64 = 0;
153 let mut buf = [0u8; 64 * 1024];
154
155 let result = loop {
156 match resp.read(&mut buf) {
157 Ok(0) => break Ok(()),
158 Ok(n) => {
159 if let Err(e) = file.write_all(&buf[..n]) {
160 break Err(DownloadError::Io(e));
161 }
162 downloaded += n as u64;
163 on_progress(downloaded, total);
164 }
165 Err(e) => break Err(DownloadError::Io(e)),
166 }
167 };
168
169 let flushed = file.flush();
170 drop(file);
171
172 let outcome = result
173 .and_then(|()| flushed.map_err(DownloadError::Io))
174 .and_then(|()| {
175 if total > 0 && downloaded != total {
176 Err(DownloadError::Incomplete {
177 got: downloaded,
178 expected: total,
179 })
180 } else {
181 Ok(())
182 }
183 });
184
185 if let Err(e) = outcome {
186 let _ = std::fs::remove_file(&tmp);
187 return Err(e);
188 }
189
190 std::fs::rename(&tmp, dest)?;
191 Ok(downloaded)
192}
193
194pub fn part_path(dest: &Path) -> std::path::PathBuf {
197 let mut name = dest.file_name().unwrap_or_default().to_os_string();
198 name.push(".part");
199 dest.with_file_name(name)
200}
201
202pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
205 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
206 return path.to_path_buf();
207 };
208 match name.strip_suffix(".part") {
209 Some(stripped) => path.with_file_name(stripped),
210 None => path.to_path_buf(),
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::io::BufRead;
218 use std::net::{TcpListener, TcpStream};
219 use std::sync::Arc;
220 use std::sync::atomic::{AtomicUsize, Ordering};
221
222 #[derive(Clone)]
224 enum Reply {
225 Complete(Vec<u8>),
227 Truncated {
229 claimed: usize,
230 body: Vec<u8>,
231 },
232 ChunkedTruncated(Vec<u8>),
235 ServerError,
236 }
237
238 struct StubServer {
241 addr: std::net::SocketAddr,
242 hits: Arc<AtomicUsize>,
243 shutdown: Arc<std::sync::atomic::AtomicBool>,
244 }
245
246 impl StubServer {
247 fn start(replies: Vec<Reply>) -> Self {
248 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
249 listener.set_nonblocking(true).unwrap();
250 let addr = listener.local_addr().unwrap();
251 let hits = Arc::new(AtomicUsize::new(0));
252 let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
253
254 let hits_bg = hits.clone();
255 let shutdown_bg = shutdown.clone();
256 std::thread::spawn(move || {
257 while !shutdown_bg.load(Ordering::Relaxed) {
258 match listener.accept() {
259 Ok((stream, _)) => {
260 let _ = stream.set_nonblocking(false);
262 let n = hits_bg.fetch_add(1, Ordering::SeqCst);
263 let reply = replies[n.min(replies.len() - 1)].clone();
264 serve_one(stream, reply);
265 }
266 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
267 std::thread::sleep(Duration::from_millis(5));
268 }
269 Err(_) => break,
270 }
271 }
272 });
273
274 Self {
275 addr,
276 hits,
277 shutdown,
278 }
279 }
280
281 fn url(&self) -> String {
282 format!("http://{}/file", self.addr)
283 }
284
285 fn hits(&self) -> usize {
286 self.hits.load(Ordering::SeqCst)
287 }
288 }
289
290 impl Drop for StubServer {
291 fn drop(&mut self) {
292 self.shutdown.store(true, Ordering::Relaxed);
293 }
294 }
295
296 fn serve_one(mut stream: TcpStream, reply: Reply) {
297 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
300 let mut line = String::new();
301 while reader.read_line(&mut line).unwrap_or(0) > 0 {
302 if line == "\r\n" || line == "\n" {
303 break;
304 }
305 line.clear();
306 }
307
308 match reply {
309 Reply::Complete(body) => {
310 let _ = write!(
311 stream,
312 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
313 body.len()
314 );
315 let _ = stream.write_all(&body);
316 }
317 Reply::Truncated { claimed, body } => {
318 let _ = write!(
319 stream,
320 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
321 claimed
322 );
323 let _ = stream.write_all(&body);
324 }
325 Reply::ChunkedTruncated(body) => {
326 let _ = write!(
327 stream,
328 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
329 );
330 let _ = write!(stream, "{:x}\r\n", body.len());
331 let _ = stream.write_all(&body);
332 let _ = stream.write_all(b"\r\n");
333 }
335 Reply::ServerError => {
336 let _ = write!(stream, "HTTP/1.1 500 Internal Server Error\r\n\r\n");
337 }
338 }
339 let _ = stream.flush();
340 let _ = stream.shutdown(std::net::Shutdown::Both);
341 }
342
343 fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
344 dir.path().join("nested").join("track.flac")
345 }
346
347 #[test]
348 fn complete_download_lands_at_dest() {
349 let body = vec![7u8; 200_000];
350 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
351 let dir = tempfile::tempdir().unwrap();
352 let dest = tmp_dest(&dir);
353 let client = download_client().unwrap();
354
355 let written =
356 download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {}).unwrap();
357
358 assert_eq!(written, body.len() as u64);
359 assert_eq!(std::fs::read(&dest).unwrap(), body);
360 assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
361 }
362
363 #[test]
364 fn truncated_body_errors_and_leaves_no_file() {
365 let server = StubServer::start(vec![Reply::Truncated {
366 claimed: 100_000,
367 body: vec![1u8; 4_096],
368 }]);
369 let dir = tempfile::tempdir().unwrap();
370 let dest = tmp_dest(&dir);
371 let client = download_client().unwrap();
372
373 let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
374 .expect_err("a short body must not succeed");
375
376 assert!(
377 matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
378 "unexpected error: {err}"
379 );
380 assert!(!dest.exists(), "dest must not hold a truncated file");
381 assert!(!part_path(&dest).exists(), "temp file must be removed");
382 }
383
384 #[test]
385 fn missing_content_length_truncation_errors_rather_than_completing() {
386 let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
389 let dir = tempfile::tempdir().unwrap();
390 let dest = tmp_dest(&dir);
391 let client = download_client().unwrap();
392
393 let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
394 .expect_err("a cut-off chunked body must not succeed");
395
396 assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
397 assert!(!dest.exists(), "dest must not hold a truncated file");
398 assert!(!part_path(&dest).exists(), "temp file must be removed");
399 }
400
401 #[test]
402 fn retries_transient_failure_then_succeeds() {
403 let body = vec![3u8; 50_000];
404 let server = StubServer::start(vec![
405 Reply::ServerError,
406 Reply::Truncated {
407 claimed: 50_000,
408 body: vec![3u8; 10],
409 },
410 Reply::Complete(body.clone()),
411 ]);
412 let dir = tempfile::tempdir().unwrap();
413 let dest = tmp_dest(&dir);
414 let client = download_client().unwrap();
415
416 let written =
417 download_with_retries(&dest, 3, || Ok(client.get(server.url())), |_, _| {}).unwrap();
418
419 assert_eq!(written, body.len() as u64);
420 assert_eq!(server.hits(), 3, "should have used all three attempts");
421 assert_eq!(std::fs::read(&dest).unwrap(), body);
422 }
423
424 #[test]
425 fn progress_reports_total_when_content_length_present() {
426 let body = vec![0u8; 300_000];
427 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
428 let dir = tempfile::tempdir().unwrap();
429 let dest = tmp_dest(&dir);
430 let client = download_client().unwrap();
431
432 let seen = std::sync::Mutex::new(Vec::new());
433 download_with_retries(
434 &dest,
435 1,
436 || Ok(client.get(server.url())),
437 |d, t| {
438 seen.lock().unwrap().push((d, t));
439 },
440 )
441 .unwrap();
442
443 let seen = seen.into_inner().unwrap();
444 assert!(!seen.is_empty(), "progress should be reported");
445 assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
446 assert_eq!(seen.last().unwrap().0, body.len() as u64);
447 }
448
449 #[test]
450 fn part_path_appends_rather_than_replacing_extension() {
451 let flac = part_path(Path::new("/tmp/Song.flac"));
452 let mp3 = part_path(Path::new("/tmp/Song.mp3"));
453 assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
454 assert_ne!(flac, mp3, "different codecs must not share a temp file");
455 }
456
457 #[test]
458 fn strip_part_suffix_round_trips() {
459 let dest = Path::new("/tmp/a/Song.flac");
460 assert_eq!(strip_part_suffix(&part_path(dest)), dest);
461 assert_eq!(strip_part_suffix(dest), dest);
462 }
463}