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 hold(&self) {
179 let mut s = self.state.lock();
180 while let Some(at) = s.retry_at {
181 let now = Instant::now();
182 if now >= at {
183 break;
184 }
185 self.changed.wait_for(&mut s, at - now);
186 }
187 }
188
189 fn admit(&self, cancelled: &dyn Fn() -> bool) -> bool {
193 let mut s = self.state.lock();
194 loop {
195 if parking_lot::MutexGuard::unlocked(&mut s, cancelled) {
197 return false;
198 }
199 let Some(at) = s.retry_at else {
200 return true;
201 };
202 let now = Instant::now();
203 if now >= at {
204 s.retry_at = Some(now + outage_backoff(s.failures, None));
208 return true;
209 }
210 self.changed.wait_for(&mut s, (at - now).min(CANCEL_POLL));
211 }
212 }
213
214 fn down(&self, retry_after: Option<Duration>) -> Duration {
216 let mut s = self.state.lock();
217 s.failures += 1;
218 let wait = outage_backoff(s.failures, retry_after);
219 s.retry_at = Some(Instant::now() + wait);
220 drop(s);
221 self.changed.notify_all();
223 wait
224 }
225
226 fn up(&self) {
228 let mut s = self.state.lock();
229 if s.retry_at.is_none() {
230 return;
231 }
232 log::info!(
233 "remote server answering again after {} failed tries",
234 s.failures
235 );
236 *s = OutageState::default();
237 drop(s);
238 self.changed.notify_all();
239 }
240}
241
242pub struct Patience<'a> {
244 pub outage: &'a Outage,
245 pub cancelled: &'a dyn Fn() -> bool,
247}
248
249pub fn download_client() -> reqwest::Result<reqwest::blocking::Client> {
252 reqwest::blocking::Client::builder()
253 .connect_timeout(CONNECT_TIMEOUT)
254 .timeout(STALL_TIMEOUT)
255 .user_agent(USER_AGENT)
256 .build()
257}
258
259pub fn api_client() -> reqwest::Result<reqwest::blocking::Client> {
261 reqwest::blocking::Client::builder()
262 .connect_timeout(CONNECT_TIMEOUT)
263 .timeout(API_TIMEOUT)
264 .user_agent(USER_AGENT)
265 .build()
266}
267
268pub fn download_with_retries(
282 dest: &Path,
283 attempts: u32,
284 patience: Option<Patience<'_>>,
285 request: impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
286 on_progress: impl Fn(u64, u64),
287) -> Result<u64, DownloadError> {
288 let attempts = attempts.max(1);
289 let mut failures = 0;
290
291 let err = loop {
292 if let Some(p) = &patience
293 && !p.outage.admit(p.cancelled)
294 {
295 break DownloadError::Cancelled;
296 }
297
298 let e = match attempt_download(dest, &request, &on_progress) {
299 Ok(bytes) => {
300 if let Some(p) = &patience {
301 p.outage.up();
302 }
303 return Ok(bytes);
304 }
305 Err(e) => e,
306 };
307
308 if let Some(p) = &patience {
309 if e.is_unavailable() {
310 let wait = p.outage.down(e.retry_after());
311 log::warn!(
312 "download of {}: server unavailable ({}), trying again in {:?}",
313 dest.display(),
314 e,
315 wait
316 );
317 continue;
318 }
319 p.outage.up();
320 }
321
322 failures += 1;
323 if !e.is_retryable() || failures >= attempts {
324 break e;
325 }
326 let backoff = BACKOFF_BASE * 2u32.pow(failures - 1);
327 log::warn!(
328 "download of {} failed ({}), retrying in {:?} ({}/{})",
329 dest.display(),
330 e,
331 backoff,
332 failures + 1,
333 attempts
334 );
335 std::thread::sleep(backoff);
336 };
337
338 let _ = std::fs::remove_file(part_path(dest));
342 Err(err)
343}
344
345fn attempt_download(
346 dest: &Path,
347 request: &impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
348 on_progress: &impl Fn(u64, u64),
349) -> Result<u64, DownloadError> {
350 let resp = request()?.send()?;
351 let status = resp.status();
352 if !status.is_success() {
354 return Err(DownloadError::Status {
355 status,
356 retry_after: parse_retry_after(resp.headers()),
357 });
358 }
359 if resp
364 .headers()
365 .get(reqwest::header::CONTENT_TYPE)
366 .and_then(|v| v.to_str().ok())
367 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"))
368 {
369 return Err(DownloadError::Request(
370 "server returned an error document where audio was expected".into(),
371 ));
372 }
373 stream_to_file(resp, dest, on_progress)
374}
375
376fn stream_to_file(
380 mut resp: reqwest::blocking::Response,
381 dest: &Path,
382 on_progress: &impl Fn(u64, u64),
383) -> Result<u64, DownloadError> {
384 let total = resp.content_length().unwrap_or(0);
385
386 if let Some(parent) = dest.parent() {
387 std::fs::create_dir_all(parent)?;
388 }
389
390 let tmp = part_path(dest);
391 let mut file = std::fs::File::create(&tmp)?;
392 let mut downloaded: u64 = 0;
393 let mut buf = [0u8; 64 * 1024];
394
395 let result = loop {
396 match resp.read(&mut buf) {
397 Ok(0) => break Ok(()),
398 Ok(n) => {
399 if let Err(e) = file.write_all(&buf[..n]) {
400 break Err(DownloadError::Io(e));
401 }
402 downloaded += n as u64;
403 on_progress(downloaded, total);
404 }
405 Err(e) => break Err(DownloadError::Io(e)),
406 }
407 };
408
409 let flushed = file.flush();
410 drop(file);
411
412 let outcome = result
413 .and_then(|()| flushed.map_err(DownloadError::Io))
414 .and_then(|()| {
415 if total > 0 && downloaded != total {
416 Err(DownloadError::Incomplete {
417 got: downloaded,
418 expected: total,
419 })
420 } else {
421 Ok(())
422 }
423 });
424
425 outcome?;
426 std::fs::rename(&tmp, dest)?;
427 Ok(downloaded)
428}
429
430pub fn part_path(dest: &Path) -> std::path::PathBuf {
433 let mut name = dest.file_name().unwrap_or_default().to_os_string();
434 name.push(".part");
435 dest.with_file_name(name)
436}
437
438pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
441 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
442 return path.to_path_buf();
443 };
444 match name.strip_suffix(".part") {
445 Some(stripped) => path.with_file_name(stripped),
446 None => path.to_path_buf(),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use std::io::BufRead;
454 use std::net::{TcpListener, TcpStream};
455 use std::sync::Arc;
456 use std::sync::atomic::{AtomicUsize, Ordering};
457
458 #[derive(Clone)]
460 enum Reply {
461 Complete(Vec<u8>),
463 Truncated {
465 claimed: usize,
466 body: Vec<u8>,
467 },
468 ChunkedTruncated(Vec<u8>),
471 ServerError,
472 Unavailable(Option<u64>),
474 }
475
476 struct StubServer {
479 addr: std::net::SocketAddr,
480 hits: Arc<AtomicUsize>,
481 shutdown: Arc<std::sync::atomic::AtomicBool>,
482 }
483
484 impl StubServer {
485 fn start(replies: Vec<Reply>) -> Self {
486 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
487 listener.set_nonblocking(true).unwrap();
488 let addr = listener.local_addr().unwrap();
489 let hits = Arc::new(AtomicUsize::new(0));
490 let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
491
492 let hits_bg = hits.clone();
493 let shutdown_bg = shutdown.clone();
494 std::thread::spawn(move || {
495 while !shutdown_bg.load(Ordering::Relaxed) {
496 match listener.accept() {
497 Ok((stream, _)) => {
498 let _ = stream.set_nonblocking(false);
500 let n = hits_bg.fetch_add(1, Ordering::SeqCst);
501 let reply = replies[n.min(replies.len() - 1)].clone();
502 serve_one(stream, reply);
503 }
504 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
505 std::thread::sleep(Duration::from_millis(5));
506 }
507 Err(_) => break,
508 }
509 }
510 });
511
512 Self {
513 addr,
514 hits,
515 shutdown,
516 }
517 }
518
519 fn url(&self) -> String {
520 format!("http://{}/file", self.addr)
521 }
522
523 fn hits(&self) -> usize {
524 self.hits.load(Ordering::SeqCst)
525 }
526 }
527
528 impl Drop for StubServer {
529 fn drop(&mut self) {
530 self.shutdown.store(true, Ordering::Relaxed);
531 }
532 }
533
534 fn serve_one(mut stream: TcpStream, reply: Reply) {
535 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
538 let mut line = String::new();
539 while reader.read_line(&mut line).unwrap_or(0) > 0 {
540 if line == "\r\n" || line == "\n" {
541 break;
542 }
543 line.clear();
544 }
545
546 match reply {
547 Reply::Complete(body) => {
548 let _ = write!(
549 stream,
550 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
551 body.len()
552 );
553 let _ = stream.write_all(&body);
554 }
555 Reply::Truncated { claimed, body } => {
556 let _ = write!(
557 stream,
558 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
559 claimed
560 );
561 let _ = stream.write_all(&body);
562 }
563 Reply::ChunkedTruncated(body) => {
564 let _ = write!(
565 stream,
566 "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n"
567 );
568 let _ = write!(stream, "{:x}\r\n", body.len());
569 let _ = stream.write_all(&body);
570 let _ = stream.write_all(b"\r\n");
571 }
573 Reply::ServerError => {
574 let _ = write!(
575 stream,
576 "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"
577 );
578 }
579 Reply::Unavailable(retry_after) => {
580 let header = retry_after
581 .map(|s| format!("Retry-After: {s}\r\n"))
582 .unwrap_or_default();
583 let _ = write!(
584 stream,
585 "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n{header}Content-Length: 0\r\n\r\n"
586 );
587 }
588 }
589 let _ = stream.flush();
593 let _ = stream.shutdown(std::net::Shutdown::Both);
594 }
595
596 fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
597 dir.path().join("nested").join("track.flac")
598 }
599
600 #[test]
601 fn complete_download_lands_at_dest() {
602 let body = vec![7u8; 200_000];
603 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
604 let dir = tempfile::tempdir().unwrap();
605 let dest = tmp_dest(&dir);
606 let client = download_client().unwrap();
607
608 let written =
609 download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
610 .unwrap();
611
612 assert_eq!(written, body.len() as u64);
613 assert_eq!(std::fs::read(&dest).unwrap(), body);
614 assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
615 }
616
617 #[test]
618 fn truncated_body_errors_and_leaves_no_file() {
619 let server = StubServer::start(vec![Reply::Truncated {
620 claimed: 100_000,
621 body: vec![1u8; 4_096],
622 }]);
623 let dir = tempfile::tempdir().unwrap();
624 let dest = tmp_dest(&dir);
625 let client = download_client().unwrap();
626
627 let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
628 .expect_err("a short body must not succeed");
629
630 assert!(
631 matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
632 "unexpected error: {err}"
633 );
634 assert!(!dest.exists(), "dest must not hold a truncated file");
635 assert!(!part_path(&dest).exists(), "temp file must be removed");
636 }
637
638 #[test]
639 fn missing_content_length_truncation_errors_rather_than_completing() {
640 let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
643 let dir = tempfile::tempdir().unwrap();
644 let dest = tmp_dest(&dir);
645 let client = download_client().unwrap();
646
647 let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
648 .expect_err("a cut-off chunked body must not succeed");
649
650 assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
651 assert!(!dest.exists(), "dest must not hold a truncated file");
652 assert!(!part_path(&dest).exists(), "temp file must be removed");
653 }
654
655 #[test]
656 fn retries_transient_failure_then_succeeds() {
657 let body = vec![3u8; 50_000];
658 let server = StubServer::start(vec![
659 Reply::ServerError,
660 Reply::Truncated {
661 claimed: 50_000,
662 body: vec![3u8; 10],
663 },
664 Reply::Complete(body.clone()),
665 ]);
666 let dir = tempfile::tempdir().unwrap();
667 let dest = tmp_dest(&dir);
668 let client = download_client().unwrap();
669
670 let written =
671 download_with_retries(&dest, 3, None, || Ok(client.get(server.url())), |_, _| {})
672 .unwrap();
673
674 assert_eq!(written, body.len() as u64);
675 assert_eq!(server.hits(), 3, "should have used all three attempts");
676 assert_eq!(std::fs::read(&dest).unwrap(), body);
677 }
678
679 #[cfg(unix)]
682 #[test]
683 fn retry_rewrites_the_same_part_file() {
684 use std::os::unix::fs::MetadataExt;
685
686 let server = StubServer::start(vec![
687 Reply::Truncated {
688 claimed: 50_000,
689 body: vec![3u8; 10],
690 },
691 Reply::Complete(vec![3u8; 50_000]),
692 ]);
693 let dir = tempfile::tempdir().unwrap();
694 let dest = tmp_dest(&dir);
695 let client = download_client().unwrap();
696
697 let inodes = std::sync::Mutex::new(std::collections::HashSet::new());
698 download_with_retries(
699 &dest,
700 2,
701 None,
702 || Ok(client.get(server.url())),
703 |_, _| {
704 if let Ok(meta) = std::fs::metadata(part_path(&dest)) {
705 inodes.lock().unwrap().insert(meta.ino());
706 }
707 },
708 )
709 .unwrap();
710
711 assert_eq!(inodes.lock().unwrap().len(), 1);
712 }
713
714 #[test]
715 fn progress_reports_total_when_content_length_present() {
716 let body = vec![0u8; 300_000];
717 let server = StubServer::start(vec![Reply::Complete(body.clone())]);
718 let dir = tempfile::tempdir().unwrap();
719 let dest = tmp_dest(&dir);
720 let client = download_client().unwrap();
721
722 let seen = std::sync::Mutex::new(Vec::new());
723 download_with_retries(
724 &dest,
725 1,
726 None,
727 || Ok(client.get(server.url())),
728 |d, t| {
729 seen.lock().unwrap().push((d, t));
730 },
731 )
732 .unwrap();
733
734 let seen = seen.into_inner().unwrap();
735 assert!(!seen.is_empty(), "progress should be reported");
736 assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
737 assert_eq!(seen.last().unwrap().0, body.len() as u64);
738 }
739
740 #[test]
741 fn part_path_appends_rather_than_replacing_extension() {
742 let flac = part_path(Path::new("/tmp/Song.flac"));
743 let mp3 = part_path(Path::new("/tmp/Song.mp3"));
744 assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
745 assert_ne!(flac, mp3, "different codecs must not share a temp file");
746 }
747
748 #[test]
749 fn strip_part_suffix_round_trips() {
750 let dest = Path::new("/tmp/a/Song.flac");
751 assert_eq!(strip_part_suffix(&part_path(dest)), dest);
752 assert_eq!(strip_part_suffix(dest), dest);
753 }
754
755 fn status(code: u16) -> DownloadError {
756 DownloadError::Status {
757 status: reqwest::StatusCode::from_u16(code).unwrap(),
758 retry_after: None,
759 }
760 }
761
762 #[test]
763 fn an_unanswering_server_is_told_apart_from_a_bad_track() {
764 for code in [503, 429, 502, 504] {
765 assert!(status(code).is_unavailable(), "{code} is the server");
766 }
767 for code in [404, 500, 403] {
768 assert!(!status(code).is_unavailable(), "{code} is the track");
769 }
770 assert!(
771 !DownloadError::Incomplete {
772 got: 1,
773 expected: 2
774 }
775 .is_unavailable()
776 );
777 assert!(!DownloadError::Request("error document".into()).is_unavailable());
778 }
779
780 #[test]
781 fn a_refused_connection_is_the_server_being_unavailable() {
782 let addr = TcpListener::bind("127.0.0.1:0")
784 .unwrap()
785 .local_addr()
786 .unwrap();
787 let err = download_client()
788 .unwrap()
789 .get(format!("http://{addr}/file"))
790 .send()
791 .map(|_| ())
792 .map_err(DownloadError::from)
793 .expect_err("nothing is listening");
794 assert!(err.is_unavailable(), "unexpected: {err}");
795 }
796
797 #[test]
798 fn outage_backoff_steps_then_holds_and_defers_to_retry_after() {
799 let s = Duration::from_secs;
800 let schedule: Vec<_> = (1..=5).map(|n| outage_backoff(n, None)).collect();
801 assert_eq!(schedule, [s(5), s(15), s(60), s(60), s(60)]);
802 assert_eq!(outage_backoff(1, Some(s(2))), s(2));
803 assert_eq!(outage_backoff(4, Some(s(0))), s(0));
804 assert_eq!(outage_backoff(1, Some(s(86_400))), RETRY_AFTER_CAP);
805 }
806
807 fn patient<'a>(outage: &'a Outage, cancelled: &'a dyn Fn() -> bool) -> Option<Patience<'a>> {
808 Some(Patience { outage, cancelled })
809 }
810
811 #[test]
812 fn retry_after_is_read_and_honoured() {
813 let server = StubServer::start(vec![
814 Reply::Unavailable(Some(1)),
815 Reply::Complete(vec![1u8; 1_000]),
816 ]);
817 let dir = tempfile::tempdir().unwrap();
818 let dest = tmp_dest(&dir);
819 let client = download_client().unwrap();
820 let outage = Outage::default();
821
822 let started = Instant::now();
823 download_with_retries(
824 &dest,
825 1,
826 patient(&outage, &|| false),
827 || Ok(client.get(server.url())),
828 |_, _| {},
829 )
830 .unwrap();
831
832 assert!(
833 started.elapsed() >= Duration::from_secs(1),
834 "waited as asked"
835 );
836 assert!(
837 started.elapsed() < OUTAGE_BACKOFF[0],
838 "not the default wait"
839 );
840 assert!(!outage.is_down(), "a success says the server is back");
841 }
842
843 #[test]
844 fn an_outage_does_not_spend_the_tracks_attempts() {
845 let mut replies = vec![Reply::Unavailable(Some(0)); 9];
848 replies.push(Reply::Complete(vec![2u8; 1_000]));
849 let server = StubServer::start(replies);
850 let dir = tempfile::tempdir().unwrap();
851 let dest = tmp_dest(&dir);
852 let client = download_client().unwrap();
853
854 download_with_retries(
855 &dest,
856 1,
857 patient(&Outage::default(), &|| false),
858 || Ok(client.get(server.url())),
859 |_, _| {},
860 )
861 .unwrap();
862 assert_eq!(server.hits(), 10);
863 }
864
865 #[test]
866 fn without_patience_an_outage_fails_as_before() {
867 let server = StubServer::start(vec![Reply::Unavailable(Some(0))]);
868 let dir = tempfile::tempdir().unwrap();
869 let dest = tmp_dest(&dir);
870 let client = download_client().unwrap();
871
872 let err = download_with_retries(&dest, 2, None, || Ok(client.get(server.url())), |_, _| {})
873 .expect_err("bounded attempts");
874 assert!(err.is_unavailable());
875 assert_eq!(server.hits(), 2);
876 }
877
878 #[test]
879 fn a_download_waiting_out_an_outage_stops_when_no_longer_wanted() {
880 let server = StubServer::start(vec![Reply::Unavailable(None)]);
881 let dir = tempfile::tempdir().unwrap();
882 let dest = tmp_dest(&dir);
883 let client = download_client().unwrap();
884 let outage = Outage::default();
885 let wanted = std::sync::atomic::AtomicBool::new(true);
886
887 let started = Instant::now();
888 let err = std::thread::scope(|scope| {
889 scope.spawn(|| {
890 std::thread::sleep(Duration::from_millis(200));
891 wanted.store(false, Ordering::Relaxed);
892 });
893 download_with_retries(
894 &dest,
895 3,
896 patient(&outage, &|| !wanted.load(Ordering::Relaxed)),
897 || Ok(client.get(server.url())),
898 |_, _| {},
899 )
900 .expect_err("cancelled")
901 });
902
903 assert!(matches!(err, DownloadError::Cancelled), "unexpected: {err}");
904 assert!(
905 started.elapsed() < OUTAGE_BACKOFF[0],
906 "stopped within a poll, not at the next try"
907 );
908 assert_eq!(server.hits(), 1);
909 assert!(!part_path(&dest).exists());
910 }
911
912 #[test]
913 fn hold_blocks_while_down_and_lets_go_when_the_server_answers() {
914 let outage = Outage::default();
915 outage.down(Some(Duration::from_secs(60)));
916 assert!(outage.is_down());
917
918 let started = Instant::now();
919 std::thread::scope(|scope| {
920 scope.spawn(|| {
921 std::thread::sleep(Duration::from_millis(100));
922 outage.up();
923 });
924 outage.hold();
925 });
926 let waited = started.elapsed();
927 assert!(waited >= Duration::from_millis(100), "held while down");
928 assert!(waited < Duration::from_secs(5), "released by the answer");
929 }
930
931 #[test]
934 fn a_queue_against_an_unavailable_server_fails_nothing_and_resumes() {
935 const TRACKS: usize = 6;
936 const UNANSWERED: usize = 12;
937 let mut replies = vec![Reply::Unavailable(Some(0)); UNANSWERED];
938 replies.push(Reply::Complete(vec![5u8; 10_000]));
939 let server = StubServer::start(replies);
940 let dir = tempfile::tempdir().unwrap();
941 let client = download_client().unwrap();
942 let outage = Outage::default();
943
944 let results: Vec<_> = std::thread::scope(|scope| {
945 let handles: Vec<_> = (0..TRACKS)
946 .map(|i| {
947 let (client, outage, server, dir) = (&client, &outage, &server, &dir);
948 scope.spawn(move || {
949 outage.hold();
950 let dest = dir.path().join(format!("{i}.flac"));
951 download_with_retries(
952 &dest,
953 DEFAULT_ATTEMPTS,
954 patient(outage, &|| false),
955 || Ok(client.get(server.url())),
956 |_, _| {},
957 )
958 .map(|_| dest)
959 })
960 })
961 .collect();
962 handles.into_iter().map(|h| h.join().unwrap()).collect()
963 });
964
965 for result in results {
966 let dest = result.expect("no track fails for the server being down");
967 assert_eq!(std::fs::read(dest).unwrap().len(), 10_000);
968 }
969 assert_eq!(server.hits(), UNANSWERED + TRACKS);
970 assert!(!outage.is_down());
971 }
972}