Skip to main content

aria2_core/engine/
metalink_download_command.rs

1use async_trait::async_trait;
2use futures::StreamExt;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tracing::{debug, info, warn};
6
7use crate::engine::active_output_registry::global_registry;
8use crate::engine::command::{Command, CommandStatus};
9use crate::error::{Aria2Error, FatalError, RecoverableError, Result};
10use crate::filesystem::disk_writer::{DefaultDiskWriter, DiskWriter};
11use crate::rate_limiter::{RateLimiter, RateLimiterConfig, ThrottledWriter};
12use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
13use aria2_protocol::metalink::parser::UrlEntry;
14
15pub struct MetalinkDownloadCommand {
16    group: Arc<tokio::sync::RwLock<RequestGroup>>,
17    client: reqwest::Client,
18    output_path: std::path::PathBuf,
19    started: bool,
20    completed: bool,
21    completed_bytes: u64,
22    metalink_data: Vec<u8>,
23}
24
25impl MetalinkDownloadCommand {
26    pub fn new(
27        gid: GroupId,
28        metalink_bytes: &[u8],
29        options: &DownloadOptions,
30        output_dir: Option<&str>,
31    ) -> Result<Self> {
32        let doc = aria2_protocol::metalink::parser::MetalinkDocument::parse(metalink_bytes)
33            .map_err(|e| {
34                Aria2Error::Fatal(FatalError::Config(format!("Metalink parse failed: {}", e)))
35            })?;
36
37        let file = doc.single_file().ok_or_else(|| {
38            Aria2Error::Fatal(FatalError::Config(
39                "Metalink contains multiple files or no files".into(),
40            ))
41        })?;
42
43        if file.urls.is_empty() {
44            return Err(Aria2Error::Fatal(FatalError::Config(
45                "Metalink file has no download URLs".into(),
46            )));
47        }
48
49        let dir = output_dir
50            .map(|d| d.to_string())
51            .or_else(|| options.dir.clone())
52            .unwrap_or_else(|| ".".to_string());
53
54        let filename = file.name.clone();
55        let path = std::path::PathBuf::from(&dir).join(&filename);
56
57        let urls: Vec<String> = file
58            .get_sorted_urls()
59            .iter()
60            .map(|u| u.url.clone())
61            .collect();
62        let group = RequestGroup::new(gid, urls, options.clone());
63
64        let client = reqwest::Client::builder()
65            .connect_timeout(Duration::from_secs(30))
66            .timeout(Duration::from_secs(300))
67            .user_agent("aria2-rust/0.1.0")
68            .redirect(reqwest::redirect::Policy::limited(5))
69            .build()
70            .map_err(|e| {
71                Aria2Error::Fatal(FatalError::Config(format!(
72                    "HTTP client build failed: {}",
73                    e
74                )))
75            })?;
76
77        info!(
78            "MetalinkDownloadCommand created: {} -> {} ({} mirrors)",
79            file.name,
80            path.display(),
81            file.urls.len()
82        );
83
84        Ok(Self {
85            group: Arc::new(tokio::sync::RwLock::new(group)),
86            client,
87            output_path: path,
88            started: false,
89            completed: false,
90            completed_bytes: 0,
91            metalink_data: metalink_bytes.to_vec(),
92        })
93    }
94
95    pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
96        self.group.read().await
97    }
98}
99
100#[async_trait]
101impl Command for MetalinkDownloadCommand {
102    async fn execute(&mut self) -> Result<()> {
103        if !self.started {
104            self.group.write().await.start().await?;
105            self.started = true;
106        }
107
108        let doc = aria2_protocol::metalink::parser::MetalinkDocument::parse(&self.metalink_data)
109            .map_err(|e| {
110                Aria2Error::Fatal(FatalError::Config(format!("Metalink parse error: {}", e)))
111            })?;
112
113        let file = doc.single_file().ok_or_else(|| {
114            Aria2Error::Fatal(FatalError::Config("No available file after parsing".into()))
115        })?;
116
117        let sorted_urls = file.get_sorted_urls();
118        if sorted_urls.is_empty() {
119            return Err(Aria2Error::Fatal(FatalError::Config(
120                "No download mirrors available".into(),
121            )));
122        }
123
124        if let Some(parent) = self.output_path.parent()
125            && !parent.exists()
126        {
127            std::fs::create_dir_all(parent).map_err(|e| {
128                Aria2Error::Fatal(FatalError::Config(format!("mkdir failed: {}", e)))
129            })?;
130        }
131
132        // Resolve filename collision against other active downloads.
133        // If another task is already writing to self.output_path, a unique
134        // name such as "file (1).ext" will be generated automatically.
135        let resolved_output_path = global_registry().resolve(&self.output_path).await;
136
137        // Helper closure to release the resolved path on every exit path.
138        let release_path = |path: &std::path::Path| {
139            let p = path.to_path_buf();
140            // Best-effort async release; safe to drop the spawned future.
141            #[allow(clippy::let_underscore_future)]
142            let _ = tokio::spawn(async move {
143                global_registry().release(&p).await;
144            });
145        };
146
147        let expected_size = file.size;
148        let hash_entry = file.hashes.first().cloned();
149
150        let mut last_error = None;
151
152        for url_entry in &sorted_urls {
153            debug!(
154                "Trying mirror [priority={}] : {}",
155                url_entry.priority, url_entry.url
156            );
157
158            match self.try_download_url(&url_entry.url, expected_size).await {
159                Ok(data) => {
160                    if let Some(ref hash) = hash_entry
161                        && !self.verify_hash(&data, hash)?
162                    {
163                        warn!(
164                            "Hash verification failed [{}]: trying next mirror",
165                            hash.algo.as_standard_name()
166                        );
167                        last_error = Some(Aria2Error::Recoverable(
168                            RecoverableError::TemporaryNetworkFailure {
169                                message: format!(
170                                    "Hash verification failed: {}",
171                                    hash.algo.as_standard_name()
172                                ),
173                            },
174                        ));
175                        continue;
176                    }
177
178                    let raw_writer = DefaultDiskWriter::new(&resolved_output_path);
179                    let rate_limit = {
180                        let g = self.group.read().await;
181                        g.options().max_download_limit
182                    };
183                    let mut writer: Box<dyn DiskWriter> = match rate_limit {
184                        Some(rate) if rate > 0 => Box::new(ThrottledWriter::new(
185                            raw_writer,
186                            RateLimiter::new(&RateLimiterConfig::new(Some(rate), None)),
187                        )),
188                        _ => Box::new(raw_writer),
189                    };
190                    writer.write(&data).await?;
191                    writer.finalize().await.ok();
192
193                    self.completed_bytes = data.len() as u64;
194
195                    {
196                        let mut g = self.group.write().await;
197                        g.update_progress(self.completed_bytes).await;
198                        g.update_speed(self.completed_bytes, 0).await;
199                        g.complete().await?;
200                    }
201
202                    info!(
203                        "Metalink download done: {} ({} bytes from {})",
204                        resolved_output_path.display(),
205                        self.completed_bytes,
206                        url_entry.url
207                    );
208                    self.completed = true;
209                    release_path(&resolved_output_path);
210                    return Ok(());
211                }
212                Err(e) => {
213                    warn!("Mirror download failed {}: {}", url_entry.url, e);
214                    last_error = Some(e);
215                }
216            }
217        }
218
219        release_path(&resolved_output_path);
220        Err(last_error
221            .unwrap_or_else(|| Aria2Error::Fatal(FatalError::Config("All mirrors failed".into()))))
222    }
223
224    fn status(&self) -> CommandStatus {
225        if self.completed {
226            CommandStatus::Completed
227        } else if self.completed_bytes > 0 {
228            CommandStatus::Running
229        } else {
230            CommandStatus::Pending
231        }
232    }
233
234    fn timeout(&self) -> Option<Duration> {
235        Some(Duration::from_secs(600))
236    }
237}
238
239impl MetalinkDownloadCommand {
240    async fn try_download_url(&mut self, url: &str, expected_size: Option<u64>) -> Result<Vec<u8>> {
241        let response = self.client.get(url).send().await.map_err(|e| {
242            Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
243                message: format!("HTTP request failed: {}", e),
244            })
245        })?;
246
247        let status = response.status();
248        if !status.is_success() && status.as_u16() != 206 {
249            if status.as_u16() >= 500 {
250                return Err(Aria2Error::Recoverable(RecoverableError::ServerError {
251                    code: status.as_u16(),
252                }));
253            }
254            return Err(Aria2Error::Fatal(FatalError::Config(format!(
255                "HTTP error: {}",
256                status
257            ))));
258        }
259
260        // Read Content-Length from the header directly instead of using
261        // response.content_length(), which returns the *body* size. For chunked
262        // transfer encoding or proxy-modified responses the body size may differ
263        // from the advertised header value. The header value is what the server
264        // advertised and is consistent with download_command.rs's approach.
265        let total_length = response
266            .headers()
267            .get(reqwest::header::CONTENT_LENGTH)
268            .and_then(|v| v.to_str().ok())
269            .and_then(|v| v.parse::<u64>().ok())
270            .unwrap_or(0);
271
272        {
273            let mut g = self.group.write().await;
274            g.set_total_length(total_length.max(expected_size.unwrap_or(0)))
275                .await;
276        }
277
278        let mut data = Vec::with_capacity(total_length as usize);
279        let mut stream = response.bytes_stream();
280        let _start_time = Instant::now();
281        let mut last_speed_update = Instant::now();
282        let mut last_completed = 0u64;
283
284        while let Some(chunk_result) = stream.next().await {
285            let bytes: bytes::Bytes = chunk_result.map_err(|e: reqwest::Error| {
286                Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
287                    message: e.to_string(),
288                })
289            })?;
290            data.extend_from_slice(&bytes);
291            self.completed_bytes = data.len() as u64;
292
293            let elapsed = last_speed_update.elapsed();
294            if elapsed.as_millis() >= 500 {
295                let delta = self.completed_bytes - last_completed;
296                let speed = (delta as f64 / elapsed.as_secs_f64()) as u64;
297                let g = self.group.write().await;
298                g.update_progress(self.completed_bytes).await;
299                g.update_speed(speed, 0).await;
300                last_speed_update = Instant::now();
301                last_completed = self.completed_bytes;
302            }
303        }
304
305        Ok(data)
306    }
307
308    fn verify_hash(
309        &self,
310        data: &[u8],
311        hash: &aria2_protocol::metalink::parser::HashEntry,
312    ) -> Result<bool> {
313        use aria2_protocol::metalink::parser::HashAlgorithm;
314
315        match hash.algo {
316            HashAlgorithm::Md5 => {
317                let digest = md5::compute(data);
318                Ok(format!("{:x}", digest) == hash.value)
319            }
320            HashAlgorithm::Sha1 => {
321                use sha1::Digest;
322                let mut hasher = sha1::Sha1::new();
323                hasher.update(data);
324                let result = hasher.finalize();
325                Ok(format!("{:x}", result) == hash.value)
326            }
327            HashAlgorithm::Sha256 => {
328                use sha2::Digest;
329                let mut hasher = sha2::Sha256::new();
330                hasher.update(data);
331                let result = hasher.finalize();
332                Ok(format!("{:x}", result) == hash.value)
333            }
334            HashAlgorithm::Sha512 => {
335                use sha2::Digest;
336                let mut hasher = sha2::Sha512::new();
337                hasher.update(data);
338                let result = hasher.finalize();
339                Ok(format!("{:x}", result) == hash.value)
340            }
341        }
342    }
343}
344
345// =========================================================================
346// K3 — Metalink Priority Ordering Functions
347// =========================================================================
348
349/// Sort metalink URL resources by priority descending, then by location preference.
350///
351/// Higher priority number means tried first (priority 10 before priority 1).
352/// Within same priority level, URLs matching the location preference are
353/// preferred over non-matching ones.
354///
355/// # Arguments
356///
357/// * `resources` - Slice of UrlEntry resources to sort
358/// * `location_preference` - Optional location code (e.g., "eu", "us", "jp")
359///   to boost matching URLs within same priority level
360///
361/// # Returns
362///
363/// A vector of references sorted by:
364/// 1. Priority descending (higher priority first)
365/// 2. Location preference match (matching locations first among equal priority)
366///
367/// # Example
368///
369/// ```ignore
370/// use aria2_core::engine::metalink_download_command::select_mirrors_by_priority;
371/// use aria2_protocol::metalink::parser::UrlEntry;
372///
373/// let urls = vec![
374///     UrlEntry::new("http://eu.example.com/file.bin").with_priority(2).with_location("eu"),
375///     UrlEntry::new("http://us.example.com/file.bin").with_priority(3).with_location("us"),
376/// ];
377///
378/// let sorted = select_mirrors_by_priority(&urls, "eu");
379/// // Result: [us URL (priority 3), eu URL (priority 2)]
380/// ```
381pub fn select_mirrors_by_priority<'a>(
382    resources: &'a [UrlEntry],
383    location_preference: &str,
384) -> Vec<&'a UrlEntry> {
385    let mut sorted: Vec<&'a UrlEntry> = resources.iter().collect();
386
387    sorted.sort_by(|a, b| {
388        // Primary sort: priority descending (higher priority number = more preferred)
389        let pri_cmp = b.priority.cmp(&a.priority);
390        if pri_cmp != std::cmp::Ordering::Equal {
391            return pri_cmp;
392        }
393
394        // Secondary sort: location preference (if specified and non-empty)
395        if !location_preference.is_empty() {
396            let a_matches = a
397                .location
398                .as_ref()
399                .map(|l| {
400                    l.contains(location_preference) || location_preference.contains(l.as_str())
401                })
402                .unwrap_or(false);
403            let b_matches = b
404                .location
405                .as_ref()
406                .map(|l| {
407                    l.contains(location_preference) || location_preference.contains(l.as_str())
408                })
409                .unwrap_or(false);
410
411            // Prefer matching location when priorities are equal
412            if a_matches != b_matches {
413                return b_matches.cmp(&a_matches);
414            }
415        }
416
417        std::cmp::Ordering::Equal
418    });
419
420    sorted
421}
422
423/// Try mirrors in priority order until one succeeds or all fail.
424///
425/// Iterates through sorted URL entries attempting download with each.
426/// Returns immediately on first success, or error after all attempts fail.
427///
428/// This implements automatic mirror failover for improved reliability:
429/// - Logs each attempt with index and URL
430/// - Continues to next mirror on failure
431/// - Returns downloaded data from first successful mirror
432/// - Aggregates errors if all mirrors fail
433///
434/// # Type Parameters
435///
436/// * `F` - Download function type that takes URL string and returns future
437/// * `Fut` - Future type returned by download function
438///
439/// # Arguments
440///
441/// * `sorted_urls` - Slice of URL references in priority order (first = highest priority)
442/// * `download_fn` - Async function that attempts download from a single URL
443///
444/// # Returns
445///
446/// * `Ok(Vec<u8>)` - Downloaded data from first successful mirror
447/// * `Err(String)` - Error message if all mirrors failed
448///
449/// # Example
450///
451/// ```ignore
452/// use aria2_core::engine::metalink_download_command::try_mirrors_with_failover;
453///
454/// async fn download(url: &str) -> Result<Vec<u8>, String> {
455///     // HTTP GET implementation
456/// }
457///
458/// let mirrors = vec![&url_entry1, &url_entry2];
459/// match try_mirrors_with_failover(&mirrors, &download).await {
460///     Ok(data) => println!("Downloaded {} bytes", data.len()),
461///     Err(e) => println!("All mirrors failed: {}", e),
462/// }
463/// ```
464pub async fn try_mirrors_with_failover<F, Fut>(
465    sorted_urls: &[&UrlEntry],
466    download_fn: F,
467) -> std::result::Result<Vec<u8>, String>
468where
469    F: Fn(&str) -> Fut,
470    Fut: std::future::Future<Output = std::result::Result<Vec<u8>, String>>,
471{
472    for (i, url_res) in sorted_urls.iter().enumerate() {
473        info!(
474            index = i,
475            url = %url_res.url,
476            priority = url_res.priority,
477            "Trying mirror"
478        );
479
480        match download_fn(&url_res.url).await {
481            Ok(data) => {
482                info!(
483                    index = i,
484                    size = data.len(),
485                    url = %url_res.url,
486                    "Mirror succeeded"
487                );
488                return Ok(data);
489            }
490            Err(e) => {
491                warn!(
492                    index = i,
493                    url = %url_res.url,
494                    error = %e,
495                    "Mirror failed, trying next"
496                );
497                continue;
498            }
499        }
500    }
501
502    Err(format!("All {} mirrors failed", sorted_urls.len()))
503}
504
505// =========================================================================
506// K3.3 — Tests for Metalink Priority Ordering
507// =========================================================================
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use aria2_protocol::metalink::parser::UrlEntry;
513
514    /// Test K3.3 #1: Priority descending order works correctly.
515    ///
516    /// Verifies that URLs are sorted by priority in descending order
517    /// (higher priority number = tried first).
518    #[test]
519    fn test_priority_descending_order() {
520        let urls = vec![
521            UrlEntry::new("http://mirror3.example.com/file.bin").with_priority(1),
522            UrlEntry::new("http://mirror1.example.com/file.bin").with_priority(3),
523            UrlEntry::new("http://mirror2.example.com/file.bin").with_priority(2),
524        ];
525
526        let sorted = select_mirrors_by_priority(&urls, "");
527
528        // Should be ordered by priority descending: [3, 2, 1]
529        assert_eq!(sorted.len(), 3, "Should return all URLs");
530        assert_eq!(
531            sorted[0].priority, 3,
532            "First URL should have highest priority (3)"
533        );
534        assert_eq!(
535            sorted[1].priority, 2,
536            "Second URL should have medium priority (2)"
537        );
538        assert_eq!(
539            sorted[2].priority, 1,
540            "Third URL should have lowest priority (1)"
541        );
542
543        // Verify URL ordering matches priority
544        assert!(
545            sorted[0].url.contains("mirror1"),
546            "First should be mirror1 (priority 3)"
547        );
548        assert!(
549            sorted[1].url.contains("mirror2"),
550            "Second should be mirror2 (priority 2)"
551        );
552        assert!(
553            sorted[2].url.contains("mirror3"),
554            "Third should be mirror3 (priority 1)"
555        );
556    }
557
558    /// Test K3.3 #2: Location preference boosts matching URLs among same priority.
559    ///
560    /// When multiple URLs have the same priority, those matching the location
561    /// preference should be tried first.
562    #[test]
563    fn test_location_preference_boosts_matching() {
564        let urls = vec![
565            UrlEntry::new("http://us-mirror1.example.com/file.bin")
566                .with_priority(5)
567                .with_location("us"),
568            UrlEntry::new("http://eu-mirror1.example.com/file.bin")
569                .with_priority(5)
570                .with_location("eu"),
571            UrlEntry::new("http://eu-mirror2.example.com/file.bin")
572                .with_priority(5)
573                .with_location("eu"),
574            UrlEntry::new("http://jp-mirror1.example.com/file.bin")
575                .with_priority(5)
576                .with_location("jp"),
577        ];
578
579        // Prefer EU locations
580        let sorted = select_mirrors_by_priority(&urls, "eu");
581
582        assert_eq!(sorted.len(), 4, "Should return all URLs");
583
584        // All have same priority (5), so EU ones should come first
585        let eu_urls: Vec<_> = sorted
586            .iter()
587            .filter(|u| u.location.as_deref() == Some("eu"))
588            .collect();
589
590        assert_eq!(eu_urls.len(), 2, "Should find 2 EU mirrors");
591
592        // EU mirrors should appear before non-EU mirrors
593        let first_non_eu_idx = sorted
594            .iter()
595            .position(|u| u.location.as_deref() != Some("eu"))
596            .expect("Should find at least one non-EU mirror");
597
598        let last_eu_idx = sorted
599            .iter()
600            .rposition(|u| u.location.as_deref() == Some("eu"))
601            .expect("Should find EU mirrors");
602
603        assert!(
604            last_eu_idx < first_non_eu_idx,
605            "EU mirrors should come before non-EU mirrors"
606        );
607
608        // Test with US preference
609        let sorted_us = select_mirrors_by_priority(&urls, "us");
610        let us_first = &sorted_us[0];
611        assert_eq!(
612            us_first.location.as_deref(),
613            Some("us"),
614            "US mirror should be first when preferring US"
615        );
616    }
617
618    /// Test K3.3 #3: Failover tries all mirrors then returns error when all fail.
619    ///
620    /// Verifies that try_mirrors_with_failover attempts every mirror and
621    /// returns an error message when all attempts fail.
622    #[tokio::test]
623    async fn test_failover_tries_all_then_errors() {
624        let urls = [
625            UrlEntry::new("http://mirror1.fail/file.bin").with_priority(3),
626            UrlEntry::new("http://mirror2.fail/file.bin").with_priority(2),
627            UrlEntry::new("http://mirror3.fail/file.bin").with_priority(1),
628        ];
629
630        // Download function that always fails
631        let fail_fn = |url: &str| -> std::pin::Pin<
632            Box<dyn std::future::Future<Output = std::result::Result<Vec<u8>, String>> + '_>,
633        > {
634            let url_owned = url.to_string();
635            Box::pin(async move { Err(format!("Connection refused to {}", url_owned)) })
636        };
637
638        let url_refs: Vec<&UrlEntry> = urls.iter().collect();
639
640        let result = try_mirrors_with_failover(&url_refs, fail_fn).await;
641
642        assert!(result.is_err(), "Should return error when all mirrors fail");
643
644        let error_msg = result.unwrap_err();
645        assert!(
646            error_msg.contains("All 3 mirrors failed"),
647            "Error message should indicate all 3 mirrors failed"
648        );
649    }
650
651    /// Test K3.3 #4: Single mirror succeeds immediately without failover.
652    ///
653    /// Verifies that when there's only one mirror and it succeeds,
654    /// the data is returned without attempting additional failover.
655    #[tokio::test]
656    async fn test_single_mirror_no_failover_needed() {
657        let urls =
658            [UrlEntry::new("http://working-mirror.example.com/success.bin").with_priority(10)];
659
660        let expected_data = b"Downloaded file content".to_vec();
661
662        // Download function that succeeds immediately
663        // Use Arc so data can be cloned multiple times (Fn trait requirement)
664        let data_shared = std::sync::Arc::new(expected_data.clone());
665        let success_fn = move |_url: &str| {
666            let data = data_shared.clone();
667            async move { Ok((*data).clone()) }
668        };
669
670        let result = try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &success_fn).await;
671
672        assert!(result.is_ok(), "Single working mirror should succeed");
673
674        let downloaded_data = result.unwrap();
675        assert_eq!(
676            downloaded_data, expected_data,
677            "Downloaded data should match expected content"
678        );
679        assert_eq!(
680            downloaded_data.len(),
681            expected_data.len(),
682            "Should download exactly {} bytes",
683            expected_data.len()
684        );
685    }
686
687    /// Additional test: Mixed priorities with location preference.
688    ///
689    /// Verifies that primary sort (priority) takes precedence over secondary
690    /// sort (location). A higher-priority non-matching URL should still come
691    /// before a lower-priority matching URL.
692    #[test]
693    fn test_priority_overrides_location() {
694        let urls = vec![
695            UrlEntry::new("http://low-eu.example.com/file.bin")
696                .with_priority(1)
697                .with_location("eu"), // Low priority but matches location
698            UrlEntry::new("http://high-us.example.com/file.bin")
699                .with_priority(10)
700                .with_location("us"), // High priority but doesn't match
701        ];
702
703        let sorted = select_mirrors_by_priority(&urls, "eu");
704
705        // High priority (10) should come first even though it doesn't match location
706        assert_eq!(
707            sorted[0].priority, 10,
708            "Higher priority URL should come first regardless of location match"
709        );
710        assert_eq!(
711            sorted[0].url, "http://high-us.example.com/file.bin",
712            "First should be high-priority US URL"
713        );
714        assert_eq!(
715            sorted[1].priority, 1,
716            "Lower priority URL should come second"
717        );
718    }
719
720    /// Additional test: Empty resource list returns empty result.
721    #[test]
722    fn test_empty_resources_returns_empty() {
723        let urls: Vec<UrlEntry> = Vec::new();
724        let sorted = select_mirrors_by_priority(&urls, "");
725
726        assert!(sorted.is_empty(), "Empty input should produce empty output");
727    }
728
729    /// Additional test: Failover succeeds on second attempt after first fails.
730    #[tokio::test]
731    async fn test_failover_succeeds_on_second_mirror() {
732        let urls = [
733            UrlEntry::new("http://failing-mirror.example.com/file.bin").with_priority(5),
734            UrlEntry::new("http://working-mirror.example.com/file.bin").with_priority(3),
735        ];
736
737        let attempt_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
738        let count_clone = attempt_count.clone();
739        let fallback_fn = move |url: &str| {
740            let url_owned = url.to_string();
741            let count = count_clone.clone();
742            async move {
743                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
744                if url_owned.contains("failing") {
745                    Err("Connection timeout".to_string())
746                } else {
747                    Ok(b"Success data".to_vec())
748                }
749            }
750        };
751
752        let result =
753            try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &fallback_fn).await;
754
755        assert!(result.is_ok(), "Should succeed on second mirror");
756        assert_eq!(
757            attempt_count.load(std::sync::atomic::Ordering::SeqCst),
758            2,
759            "Should have attempted 2 mirrors"
760        );
761
762        let data = result.unwrap();
763        assert_eq!(
764            data, b"Success data",
765            "Data from second mirror should be returned"
766        );
767    }
768}