1use std::io::{Read, Write};
8use std::path::Path;
9use std::time::{Duration, Instant};
10
11use parking_lot::{Condvar, Mutex};
12use thiserror::Error;
13
14const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16
17const STALL_TIMEOUT: Duration = Duration::from_secs(30);
23
24pub const API_TIMEOUT: Duration = Duration::from_secs(30);
26
27#[cfg(target_os = "macos")]
30const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (Macintosh)");
31#[cfg(target_os = "ios")]
32const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (iOS)");
33#[cfg(target_os = "tvos")]
34const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (tvOS)");
35#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))]
36const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (Linux)");
37
38pub const DEFAULT_ATTEMPTS: u32 = 3;
40
41const BACKOFF_BASE: Duration = Duration::from_millis(500);
43
44const OUTAGE_BACKOFF: [Duration; 3] = [
47 Duration::from_secs(5),
48 Duration::from_secs(15),
49 Duration::from_secs(60),
50];
51
52const RETRY_AFTER_CAP: Duration = Duration::from_secs(600);
56
57const CANCEL_POLL: Duration = Duration::from_secs(1);
59
60#[derive(Debug, Error)]
61pub enum DownloadError {
62 #[error("http error: {0}")]
63 Http(reqwest::Error),
64 #[error("io error: {0}")]
65 Io(#[from] std::io::Error),
66 #[error("incomplete download: got {got} of {expected} bytes")]
67 Incomplete { got: u64, expected: u64 },
68 #[error("server returned {status}")]
69 Status {
70 status: reqwest::StatusCode,
71 retry_after: Option<Duration>,
73 },
74 #[error("request could not be built: {0}")]
75 Request(String),
76 #[error("no longer wanted")]
77 Cancelled,
78}
79
80impl From<reqwest::Error> for DownloadError {
83 fn from(e: reqwest::Error) -> Self {
84 Self::Http(e.without_url())
85 }
86}
87
88impl DownloadError {
89 pub fn is_retryable(&self) -> bool {
92 match self {
93 DownloadError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
94 DownloadError::Io(_) | DownloadError::Incomplete { .. } => true,
95 DownloadError::Status { status, .. } => {
96 status.is_server_error() || *status == reqwest::StatusCode::TOO_MANY_REQUESTS
97 }
98 DownloadError::Request(_) | DownloadError::Cancelled => false,
99 }
100 }
101
102 pub fn is_unavailable(&self) -> bool {
108 use reqwest::StatusCode;
109 match self {
110 DownloadError::Http(e) => e.is_connect(),
112 DownloadError::Status { status, .. } => matches!(
113 *status,
114 StatusCode::SERVICE_UNAVAILABLE
115 | StatusCode::TOO_MANY_REQUESTS
116 | StatusCode::BAD_GATEWAY
117 | StatusCode::GATEWAY_TIMEOUT
118 ),
119 _ => false,
120 }
121 }
122
123 fn retry_after(&self) -> Option<Duration> {
124 match self {
125 DownloadError::Status { retry_after, .. } => *retry_after,
126 _ => None,
127 }
128 }
129}
130
131pub fn outage_backoff(failures: u32, retry_after: Option<Duration>) -> Duration {
134 if let Some(wait) = retry_after {
135 return wait.min(RETRY_AFTER_CAP);
136 }
137 let step = (failures.max(1) - 1) as usize;
138 OUTAGE_BACKOFF[step.min(OUTAGE_BACKOFF.len() - 1)]
139}
140
141fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
143 headers
144 .get(reqwest::header::RETRY_AFTER)?
145 .to_str()
146 .ok()?
147 .trim()
148 .parse()
149 .ok()
150 .map(Duration::from_secs)
151}
152
153#[derive(Default)]
160pub struct Outage {
161 state: Mutex<OutageState>,
162 changed: Condvar,
163}
164
165#[derive(Default)]
166struct OutageState {
167 failures: u32,
169 retry_at: Option<Instant>,
171}
172
173impl Outage {
174 pub fn is_down(&self) -> bool {
175 self.state.lock().retry_at.is_some()
176 }
177
178 pub fn retry_now(&self) {
181 let mut s = self.state.lock();
182 if s.retry_at.is_none() {
183 return;
184 }
185 s.retry_at = Some(Instant::now());
186 drop(s);
187 self.changed.notify_all();
188 }
189
190 pub fn hold(&self) {
193 let mut s = self.state.lock();
194 while let Some(at) = s.retry_at {
195 let now = Instant::now();
196 if now >= at {
197 break;
198 }
199 self.changed.wait_for(&mut s, at - now);
200 }
201 }
202
203 fn admit(&self, cancelled: &dyn Fn() -> bool) -> bool {
207 let mut s = self.state.lock();
208 loop {
209 if parking_lot::MutexGuard::unlocked(&mut s, cancelled) {
211 return false;
212 }
213 let Some(at) = s.retry_at else {
214 return true;
215 };
216 let now = Instant::now();
217 if now >= at {
218 s.retry_at = Some(now + outage_backoff(s.failures, None));
222 return true;
223 }
224 self.changed.wait_for(&mut s, (at - now).min(CANCEL_POLL));
225 }
226 }
227
228 fn down(&self, retry_after: Option<Duration>) -> Duration {
230 let mut s = self.state.lock();
231 s.failures += 1;
232 let wait = outage_backoff(s.failures, retry_after);
233 s.retry_at = Some(Instant::now() + wait);
234 drop(s);
235 self.changed.notify_all();
237 wait
238 }
239
240 fn up(&self) {
242 let mut s = self.state.lock();
243 if s.retry_at.is_none() {
244 return;
245 }
246 log::info!(
247 "remote server answering again after {} failed tries",
248 s.failures
249 );
250 *s = OutageState::default();
251 drop(s);
252 self.changed.notify_all();
253 }
254}
255
256pub struct Patience<'a> {
258 pub outage: &'a Outage,
259 pub cancelled: &'a dyn Fn() -> bool,
262}
263
264pub fn download_client() -> reqwest::Result<reqwest::blocking::Client> {
267 reqwest::blocking::Client::builder()
268 .connect_timeout(CONNECT_TIMEOUT)
269 .timeout(STALL_TIMEOUT)
270 .user_agent(USER_AGENT)
271 .build()
272}
273
274pub fn api_client() -> reqwest::Result<reqwest::blocking::Client> {
276 reqwest::blocking::Client::builder()
277 .connect_timeout(CONNECT_TIMEOUT)
278 .timeout(API_TIMEOUT)
279 .user_agent(USER_AGENT)
280 .build()
281}
282
283pub fn download_with_retries(
297 dest: &Path,
298 attempts: u32,
299 patience: Option<Patience<'_>>,
300 request: impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
301 on_progress: impl Fn(u64, u64),
302) -> Result<u64, DownloadError> {
303 let attempts = attempts.max(1);
304 let mut failures = 0;
305 let cancelled: &dyn Fn() -> bool = match &patience {
306 Some(p) => p.cancelled,
307 None => &|| false,
308 };
309
310 let err = loop {
311 if let Some(p) = &patience
312 && !p.outage.admit(p.cancelled)
313 {
314 break DownloadError::Cancelled;
315 }
316
317 let e = match attempt_download(dest, &request, &on_progress, cancelled) {
318 Ok(bytes) => {
319 if let Some(p) = &patience {
320 p.outage.up();
321 }
322 return Ok(bytes);
323 }
324 Err(e) => e,
325 };
326
327 if let Some(p) = &patience {
328 if e.is_unavailable() {
329 let wait = p.outage.down(e.retry_after());
330 log::warn!(
331 "download of {}: server unavailable ({}), trying again in {:?}",
332 dest.display(),
333 e,
334 wait
335 );
336 continue;
337 }
338 p.outage.up();
339 }
340
341 failures += 1;
342 if !e.is_retryable() || failures >= attempts {
343 break e;
344 }
345 let backoff = BACKOFF_BASE * 2u32.pow(failures - 1);
346 log::warn!(
347 "download of {} failed ({}), retrying in {:?} ({}/{})",
348 dest.display(),
349 e,
350 backoff,
351 failures + 1,
352 attempts
353 );
354 std::thread::sleep(backoff);
355 };
356
357 let _ = std::fs::remove_file(part_path(dest));
361 Err(err)
362}
363
364fn attempt_download(
365 dest: &Path,
366 request: &impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
367 on_progress: &impl Fn(u64, u64),
368 cancelled: &dyn Fn() -> bool,
369) -> Result<u64, DownloadError> {
370 let resp = request()?.send()?;
371 let status = resp.status();
372 if !status.is_success() {
374 return Err(DownloadError::Status {
375 status,
376 retry_after: parse_retry_after(resp.headers()),
377 });
378 }
379 if resp
384 .headers()
385 .get(reqwest::header::CONTENT_TYPE)
386 .and_then(|v| v.to_str().ok())
387 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"))
388 {
389 return Err(DownloadError::Request(
390 "server returned an error document where audio was expected".into(),
391 ));
392 }
393 stream_to_file(resp, dest, on_progress, cancelled)
394}
395
396fn stream_to_file(
400 mut resp: reqwest::blocking::Response,
401 dest: &Path,
402 on_progress: &impl Fn(u64, u64),
403 cancelled: &dyn Fn() -> bool,
404) -> Result<u64, DownloadError> {
405 let total = resp.content_length().unwrap_or(0);
406
407 if let Some(parent) = dest.parent() {
408 std::fs::create_dir_all(parent)?;
409 }
410
411 let tmp = part_path(dest);
412 let mut file = std::fs::File::create(&tmp)?;
413 let mut downloaded: u64 = 0;
414 let mut buf = [0u8; 64 * 1024];
415
416 let result = loop {
417 match resp.read(&mut buf) {
418 Ok(0) => break Ok(()),
419 Ok(n) => {
420 if let Err(e) = file.write_all(&buf[..n]) {
421 break Err(DownloadError::Io(e));
422 }
423 downloaded += n as u64;
424 on_progress(downloaded, total);
425 if cancelled() {
426 break Err(DownloadError::Cancelled);
427 }
428 }
429 Err(e) => break Err(DownloadError::Io(e)),
430 }
431 };
432
433 let flushed = file.flush();
434 drop(file);
435
436 let outcome = result
437 .and_then(|()| flushed.map_err(DownloadError::Io))
438 .and_then(|()| {
439 if total > 0 && downloaded != total {
440 Err(DownloadError::Incomplete {
441 got: downloaded,
442 expected: total,
443 })
444 } else {
445 Ok(())
446 }
447 });
448
449 outcome?;
450 std::fs::rename(&tmp, dest)?;
451 Ok(downloaded)
452}
453
454pub fn part_path(dest: &Path) -> std::path::PathBuf {
457 let mut name = dest.file_name().unwrap_or_default().to_os_string();
458 name.push(".part");
459 dest.with_file_name(name)
460}
461
462pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
465 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
466 return path.to_path_buf();
467 };
468 match name.strip_suffix(".part") {
469 Some(stripped) => path.with_file_name(stripped),
470 None => path.to_path_buf(),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::io::BufRead;
478 use std::net::{TcpListener, TcpStream};
479 use std::sync::Arc;
480 use std::sync::atomic::{AtomicUsize, Ordering};
481
482 #[derive(Clone)]
484 enum Reply {
485 Complete(Vec<u8>),
487 Truncated {
489 claimed: usize,
490 body: Vec<u8>,
491 },
492 ChunkedTruncated(Vec<u8>),
495 ServerError,
496 Unavailable(Option<u64>),
498 }
499
500 struct StubServer {
503 addr: std::net::SocketAddr,
504 hits: Arc<AtomicUsize>,
505 shutdown: Arc<std::sync::atomic::AtomicBool>,
506 }
507
508 impl StubServer {
509 fn start(replies: Vec<Reply>) -> Self {
510 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
511 listener.set_nonblocking(true).unwrap();
512 let addr = listener.local_addr().unwrap();
513 let hits = Arc::new(AtomicUsize::new(0));
514 let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
515
516 let hits_bg = hits.clone();
517 let shutdown_bg = shutdown.clone();
518 std::thread::spawn(move || {
519 while !shutdown_bg.load(Ordering::Relaxed) {
520 match listener.accept() {
521 Ok((stream, _)) => {
522 let _ = stream.set_nonblocking(false);
524 let n = hits_bg.fetch_add(1, Ordering::SeqCst);
525 let reply = replies[n.min(replies.len() - 1)].clone();
526 serve_one(stream, reply);
527 }
528 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
529 std::thread::sleep(Duration::from_millis(5));
530 }
531 Err(_) => break,
532 }
533 }
534 });
535
536 Self {
537 addr,
538 hits,
539 shutdown,
540 }
541 }
542
543 fn url(&self) -> String {
544 format!("http://{}/file", self.addr)
545 }
546
547 fn hits(&self) -> usize {
548 self.hits.load(Ordering::SeqCst)
549 }
550 }
551
552 impl Drop for StubServer {
553 fn drop(&mut self) {
554 self.shutdown.store(true, Ordering::Relaxed);
555 }
556 }
557
558 fn serve_one(mut stream: TcpStream, reply: Reply) {
559 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
562 let mut line = String::new();
563 while reader.read_line(&mut line).unwrap_or(0) > 0 {
564 if line == "\r\n" || line == "\n" {
565 break;
566 }
567 line.clear();
568 }
569
570 match reply {
571 Reply::Complete(body) => {
572 let _ = write!(
573 stream,
574 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
575 body.len()
576 );
577 let _ = stream.write_all(&body);
578 }
579 Reply::Truncated { claimed, body } => {
580 let _ = write!(
581 stream,
582 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
583 claimed
584 );
585 let _ = stream.write_all(&body);
586 }
587 Reply::ChunkedTruncated(body) => {
588 let _ = write!(
589 stream,
590 "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n"
591 );
592 let _ = write!(stream, "{:x}\r\n", body.len());
593 let _ = stream.write_all(&body);
594 let _ = stream.write_all(b"\r\n");
595 }
597 Reply::ServerError => {
598 let _ = write!(
599 stream,
600 "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"
601 );
602 }
603 Reply::Unavailable(retry_after) => {
604 let header = retry_after
605 .map(|s| format!("Retry-After: {s}\r\n"))
606 .unwrap_or_default();
607 let _ = write!(
608 stream,
609 "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n{header}Content-Length: 0\r\n\r\n"
610 );
611 }
612 }
613 let _ = stream.flush();
617 let _ = stream.shutdown(std::net::Shutdown::Both);
618 }
619
620 fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
621 dir.path().join("nested").join("track.flac")
622 }
623
624 #[test]
625 fn complete_download_lands_at_dest() {
626 let body = vec![7u8; 200_000];
627 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
628 let dir = tempfile::tempdir().unwrap();
629 let dest = tmp_dest(&dir);
630 let client = download_client().unwrap();
631
632 let written =
633 download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
634 .unwrap();
635
636 assert_eq!(written, body.len() as u64);
637 assert_eq!(std::fs::read(&dest).unwrap(), body);
638 assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
639 }
640
641 #[test]
642 fn truncated_body_errors_and_leaves_no_file() {
643 let server = StubServer::start(vec![Reply::Truncated {
644 claimed: 100_000,
645 body: vec![1u8; 4_096],
646 }]);
647 let dir = tempfile::tempdir().unwrap();
648 let dest = tmp_dest(&dir);
649 let client = download_client().unwrap();
650
651 let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
652 .expect_err("a short body must not succeed");
653
654 assert!(
655 matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
656 "unexpected error: {err}"
657 );
658 assert!(!dest.exists(), "dest must not hold a truncated file");
659 assert!(!part_path(&dest).exists(), "temp file must be removed");
660 }
661
662 #[test]
663 fn missing_content_length_truncation_errors_rather_than_completing() {
664 let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
667 let dir = tempfile::tempdir().unwrap();
668 let dest = tmp_dest(&dir);
669 let client = download_client().unwrap();
670
671 let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
672 .expect_err("a cut-off chunked body must not succeed");
673
674 assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
675 assert!(!dest.exists(), "dest must not hold a truncated file");
676 assert!(!part_path(&dest).exists(), "temp file must be removed");
677 }
678
679 #[test]
680 fn retries_transient_failure_then_succeeds() {
681 let body = vec![3u8; 50_000];
682 let server = StubServer::start(vec![
683 Reply::ServerError,
684 Reply::Truncated {
685 claimed: 50_000,
686 body: vec![3u8; 10],
687 },
688 Reply::Complete(body.clone()),
689 ]);
690 let dir = tempfile::tempdir().unwrap();
691 let dest = tmp_dest(&dir);
692 let client = download_client().unwrap();
693
694 let written =
695 download_with_retries(&dest, 3, None, || Ok(client.get(server.url())), |_, _| {})
696 .unwrap();
697
698 assert_eq!(written, body.len() as u64);
699 assert_eq!(server.hits(), 3, "should have used all three attempts");
700 assert_eq!(std::fs::read(&dest).unwrap(), body);
701 }
702
703 #[cfg(unix)]
706 #[test]
707 fn retry_rewrites_the_same_part_file() {
708 use std::os::unix::fs::MetadataExt;
709
710 let server = StubServer::start(vec![
711 Reply::Truncated {
712 claimed: 50_000,
713 body: vec![3u8; 10],
714 },
715 Reply::Complete(vec![3u8; 50_000]),
716 ]);
717 let dir = tempfile::tempdir().unwrap();
718 let dest = tmp_dest(&dir);
719 let client = download_client().unwrap();
720
721 let inodes = std::sync::Mutex::new(std::collections::HashSet::new());
722 download_with_retries(
723 &dest,
724 2,
725 None,
726 || Ok(client.get(server.url())),
727 |_, _| {
728 if let Ok(meta) = std::fs::metadata(part_path(&dest)) {
729 inodes.lock().unwrap().insert(meta.ino());
730 }
731 },
732 )
733 .unwrap();
734
735 assert_eq!(inodes.lock().unwrap().len(), 1);
736 }
737
738 #[test]
739 fn progress_reports_total_when_content_length_present() {
740 let body = vec![0u8; 300_000];
741 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
742 let dir = tempfile::tempdir().unwrap();
743 let dest = tmp_dest(&dir);
744 let client = download_client().unwrap();
745
746 let seen = std::sync::Mutex::new(Vec::new());
747 download_with_retries(
748 &dest,
749 1,
750 None,
751 || Ok(client.get(server.url())),
752 |d, t| {
753 seen.lock().unwrap().push((d, t));
754 },
755 )
756 .unwrap();
757
758 let seen = seen.into_inner().unwrap();
759 assert!(!seen.is_empty(), "progress should be reported");
760 assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
761 assert_eq!(seen.last().unwrap().0, body.len() as u64);
762 }
763
764 #[test]
765 fn part_path_appends_rather_than_replacing_extension() {
766 let flac = part_path(Path::new("/tmp/Song.flac"));
767 let mp3 = part_path(Path::new("/tmp/Song.mp3"));
768 assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
769 assert_ne!(flac, mp3, "different codecs must not share a temp file");
770 }
771
772 #[test]
773 fn strip_part_suffix_round_trips() {
774 let dest = Path::new("/tmp/a/Song.flac");
775 assert_eq!(strip_part_suffix(&part_path(dest)), dest);
776 assert_eq!(strip_part_suffix(dest), dest);
777 }
778
779 fn status(code: u16) -> DownloadError {
780 DownloadError::Status {
781 status: reqwest::StatusCode::from_u16(code).unwrap(),
782 retry_after: None,
783 }
784 }
785
786 #[test]
787 fn an_unanswering_server_is_told_apart_from_a_bad_track() {
788 for code in [503, 429, 502, 504] {
789 assert!(status(code).is_unavailable(), "{code} is the server");
790 }
791 for code in [404, 500, 403] {
792 assert!(!status(code).is_unavailable(), "{code} is the track");
793 }
794 assert!(
795 !DownloadError::Incomplete {
796 got: 1,
797 expected: 2
798 }
799 .is_unavailable()
800 );
801 assert!(!DownloadError::Request("error document".into()).is_unavailable());
802 }
803
804 #[test]
805 fn a_refused_connection_is_the_server_being_unavailable() {
806 let addr = TcpListener::bind("127.0.0.1:0")
808 .unwrap()
809 .local_addr()
810 .unwrap();
811 let err = download_client()
812 .unwrap()
813 .get(format!("http://{addr}/file"))
814 .send()
815 .map(|_| ())
816 .map_err(DownloadError::from)
817 .expect_err("nothing is listening");
818 assert!(err.is_unavailable(), "unexpected: {err}");
819 }
820
821 #[test]
822 fn asking_for_music_cuts_an_outage_wait_short() {
823 let outage = Outage::default();
824 outage.down(Some(Duration::from_secs(600)));
825 outage.retry_now();
826 let started = Instant::now();
827 outage.hold();
828 assert!(
829 started.elapsed() < Duration::from_secs(1),
830 "a try is due at once"
831 );
832 assert!(outage.is_down(), "still down until a try succeeds");
833
834 let wait = outage.down(None);
836 assert_eq!(wait, outage_backoff(2, None));
837
838 let up = Outage::default();
840 up.retry_now();
841 assert!(!up.is_down());
842 }
843
844 #[test]
845 fn outage_backoff_steps_then_holds_and_defers_to_retry_after() {
846 let s = Duration::from_secs;
847 let schedule: Vec<_> = (1..=5).map(|n| outage_backoff(n, None)).collect();
848 assert_eq!(schedule, [s(5), s(15), s(60), s(60), s(60)]);
849 assert_eq!(outage_backoff(1, Some(s(2))), s(2));
850 assert_eq!(outage_backoff(4, Some(s(0))), s(0));
851 assert_eq!(outage_backoff(1, Some(s(86_400))), RETRY_AFTER_CAP);
852 }
853
854 fn patient<'a>(outage: &'a Outage, cancelled: &'a dyn Fn() -> bool) -> Option<Patience<'a>> {
855 Some(Patience { outage, cancelled })
856 }
857
858 #[test]
859 fn retry_after_is_read_and_honoured() {
860 let server = StubServer::start(vec![
861 Reply::Unavailable(Some(1)),
862 Reply::Complete(vec![1u8; 1_000]),
863 ]);
864 let dir = tempfile::tempdir().unwrap();
865 let dest = tmp_dest(&dir);
866 let client = download_client().unwrap();
867 let outage = Outage::default();
868
869 let started = Instant::now();
870 download_with_retries(
871 &dest,
872 1,
873 patient(&outage, &|| false),
874 || Ok(client.get(server.url())),
875 |_, _| {},
876 )
877 .unwrap();
878
879 assert!(
880 started.elapsed() >= Duration::from_secs(1),
881 "waited as asked"
882 );
883 assert!(
884 started.elapsed() < OUTAGE_BACKOFF[0],
885 "not the default wait"
886 );
887 assert!(!outage.is_down(), "a success says the server is back");
888 }
889
890 #[test]
891 fn an_outage_does_not_spend_the_tracks_attempts() {
892 let mut replies = vec![Reply::Unavailable(Some(0)); 9];
895 replies.push(Reply::Complete(vec![2u8; 1_000]));
896 let server = StubServer::start(replies);
897 let dir = tempfile::tempdir().unwrap();
898 let dest = tmp_dest(&dir);
899 let client = download_client().unwrap();
900
901 download_with_retries(
902 &dest,
903 1,
904 patient(&Outage::default(), &|| false),
905 || Ok(client.get(server.url())),
906 |_, _| {},
907 )
908 .unwrap();
909 assert_eq!(server.hits(), 10);
910 }
911
912 #[test]
913 fn without_patience_an_outage_fails_as_before() {
914 let server = StubServer::start(vec![Reply::Unavailable(Some(0))]);
915 let dir = tempfile::tempdir().unwrap();
916 let dest = tmp_dest(&dir);
917 let client = download_client().unwrap();
918
919 let err = download_with_retries(&dest, 2, None, || Ok(client.get(server.url())), |_, _| {})
920 .expect_err("bounded attempts");
921 assert!(err.is_unavailable());
922 assert_eq!(server.hits(), 2);
923 }
924
925 #[test]
926 fn a_download_waiting_out_an_outage_stops_when_no_longer_wanted() {
927 let server = StubServer::start(vec![Reply::Unavailable(None)]);
928 let dir = tempfile::tempdir().unwrap();
929 let dest = tmp_dest(&dir);
930 let client = download_client().unwrap();
931 let outage = Outage::default();
932 let wanted = std::sync::atomic::AtomicBool::new(true);
933
934 let started = Instant::now();
935 let err = std::thread::scope(|scope| {
936 scope.spawn(|| {
937 std::thread::sleep(Duration::from_millis(200));
938 wanted.store(false, Ordering::Relaxed);
939 });
940 download_with_retries(
941 &dest,
942 3,
943 patient(&outage, &|| !wanted.load(Ordering::Relaxed)),
944 || Ok(client.get(server.url())),
945 |_, _| {},
946 )
947 .expect_err("cancelled")
948 });
949
950 assert!(matches!(err, DownloadError::Cancelled), "unexpected: {err}");
951 assert!(
952 started.elapsed() < OUTAGE_BACKOFF[0],
953 "stopped within a poll, not at the next try"
954 );
955 assert_eq!(server.hits(), 1);
956 assert!(!part_path(&dest).exists());
957 }
958
959 #[test]
960 fn a_download_no_longer_wanted_stops_mid_transfer() {
961 let server = StubServer::start(vec![Reply::Complete(vec![7u8; 4 * 1024 * 1024])]);
964 let dir = tempfile::tempdir().unwrap();
965 let dest = tmp_dest(&dir);
966 let client = download_client().unwrap();
967 let outage = Outage::default();
968 let received = AtomicUsize::new(0);
969
970 let err = download_with_retries(
971 &dest,
972 3,
973 patient(&outage, &|| received.load(Ordering::Relaxed) > 0),
974 || Ok(client.get(server.url())),
975 |bytes, _| received.store(bytes as usize, Ordering::Relaxed),
976 )
977 .expect_err("cancelled");
978
979 assert!(matches!(err, DownloadError::Cancelled), "unexpected: {err}");
980 assert!(received.load(Ordering::Relaxed) < 4 * 1024 * 1024);
981 assert_eq!(server.hits(), 1, "not retried");
982 assert!(!dest.exists());
983 assert!(!part_path(&dest).exists());
984 }
985
986 #[test]
987 fn hold_blocks_while_down_and_lets_go_when_the_server_answers() {
988 let outage = Outage::default();
989 outage.down(Some(Duration::from_secs(60)));
990 assert!(outage.is_down());
991
992 let started = Instant::now();
993 std::thread::scope(|scope| {
994 scope.spawn(|| {
995 std::thread::sleep(Duration::from_millis(100));
996 outage.up();
997 });
998 outage.hold();
999 });
1000 let waited = started.elapsed();
1001 assert!(waited >= Duration::from_millis(100), "held while down");
1002 assert!(waited < Duration::from_secs(5), "released by the answer");
1003 }
1004
1005 #[test]
1008 fn a_queue_against_an_unavailable_server_fails_nothing_and_resumes() {
1009 const TRACKS: usize = 6;
1010 const UNANSWERED: usize = 12;
1011 let mut replies = vec![Reply::Unavailable(Some(0)); UNANSWERED];
1012 replies.push(Reply::Complete(vec![5u8; 10_000]));
1013 let server = StubServer::start(replies);
1014 let dir = tempfile::tempdir().unwrap();
1015 let client = download_client().unwrap();
1016 let outage = Outage::default();
1017
1018 let results: Vec<_> = std::thread::scope(|scope| {
1019 let handles: Vec<_> = (0..TRACKS)
1020 .map(|i| {
1021 let (client, outage, server, dir) = (&client, &outage, &server, &dir);
1022 scope.spawn(move || {
1023 outage.hold();
1024 let dest = dir.path().join(format!("{i}.flac"));
1025 download_with_retries(
1026 &dest,
1027 DEFAULT_ATTEMPTS,
1028 patient(outage, &|| false),
1029 || Ok(client.get(server.url())),
1030 |_, _| {},
1031 )
1032 .map(|_| dest)
1033 })
1034 })
1035 .collect();
1036 handles.into_iter().map(|h| h.join().unwrap()).collect()
1037 });
1038
1039 for result in results {
1040 let dest = result.expect("no track fails for the server being down");
1041 assert_eq!(std::fs::read(dest).unwrap().len(), 10_000);
1042 }
1043 assert_eq!(server.hits(), UNANSWERED + TRACKS);
1044 assert!(!outage.is_down());
1045 }
1046}