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