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        let total_length = response.content_length().unwrap_or(0) as u64;
261
262        {
263            let mut g = self.group.write().await;
264            g.set_total_length(total_length.max(expected_size.unwrap_or(0)))
265                .await;
266        }
267
268        let mut data = Vec::with_capacity(total_length as usize);
269        let mut stream = response.bytes_stream();
270        let _start_time = Instant::now();
271        let mut last_speed_update = Instant::now();
272        let mut last_completed = 0u64;
273
274        while let Some(chunk_result) = stream.next().await {
275            let bytes: bytes::Bytes = chunk_result.map_err(|e: reqwest::Error| {
276                Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
277                    message: e.to_string(),
278                })
279            })?;
280            data.extend_from_slice(&bytes);
281            self.completed_bytes = data.len() as u64;
282
283            let elapsed = last_speed_update.elapsed();
284            if elapsed.as_millis() >= 500 {
285                let delta = self.completed_bytes - last_completed;
286                let speed = (delta as f64 / elapsed.as_secs_f64()) as u64;
287                let g = self.group.write().await;
288                g.update_progress(self.completed_bytes).await;
289                g.update_speed(speed, 0).await;
290                last_speed_update = Instant::now();
291                last_completed = self.completed_bytes;
292            }
293        }
294
295        Ok(data)
296    }
297
298    fn verify_hash(
299        &self,
300        data: &[u8],
301        hash: &aria2_protocol::metalink::parser::HashEntry,
302    ) -> Result<bool> {
303        use aria2_protocol::metalink::parser::HashAlgorithm;
304
305        match hash.algo {
306            HashAlgorithm::Md5 => {
307                let digest = md5::compute(data);
308                Ok(format!("{:x}", digest) == hash.value)
309            }
310            HashAlgorithm::Sha1 => {
311                use sha1::Digest;
312                let mut hasher = sha1::Sha1::new();
313                hasher.update(data);
314                let result = hasher.finalize();
315                Ok(format!("{:x}", result) == hash.value)
316            }
317            HashAlgorithm::Sha256 => {
318                use sha2::Digest;
319                let mut hasher = sha2::Sha256::new();
320                hasher.update(data);
321                let result = hasher.finalize();
322                Ok(format!("{:x}", result) == hash.value)
323            }
324            HashAlgorithm::Sha512 => {
325                use sha2::Digest;
326                let mut hasher = sha2::Sha512::new();
327                hasher.update(data);
328                let result = hasher.finalize();
329                Ok(format!("{:x}", result) == hash.value)
330            }
331        }
332    }
333}
334
335// =========================================================================
336// K3 — Metalink Priority Ordering Functions
337// =========================================================================
338
339/// Sort metalink URL resources by priority descending, then by location preference.
340///
341/// Higher priority number means tried first (priority 10 before priority 1).
342/// Within same priority level, URLs matching the location preference are
343/// preferred over non-matching ones.
344///
345/// # Arguments
346///
347/// * `resources` - Slice of UrlEntry resources to sort
348/// * `location_preference` - Optional location code (e.g., "eu", "us", "jp")
349///   to boost matching URLs within same priority level
350///
351/// # Returns
352///
353/// A vector of references sorted by:
354/// 1. Priority descending (higher priority first)
355/// 2. Location preference match (matching locations first among equal priority)
356///
357/// # Example
358///
359/// ```ignore
360/// use aria2_core::engine::metalink_download_command::select_mirrors_by_priority;
361/// use aria2_protocol::metalink::parser::UrlEntry;
362///
363/// let urls = vec![
364///     UrlEntry::new("http://eu.example.com/file.bin").with_priority(2).with_location("eu"),
365///     UrlEntry::new("http://us.example.com/file.bin").with_priority(3).with_location("us"),
366/// ];
367///
368/// let sorted = select_mirrors_by_priority(&urls, "eu");
369/// // Result: [us URL (priority 3), eu URL (priority 2)]
370/// ```
371pub fn select_mirrors_by_priority<'a>(
372    resources: &'a [UrlEntry],
373    location_preference: &str,
374) -> Vec<&'a UrlEntry> {
375    let mut sorted: Vec<&'a UrlEntry> = resources.iter().collect();
376
377    sorted.sort_by(|a, b| {
378        // Primary sort: priority descending (higher priority number = more preferred)
379        let pri_cmp = b.priority.cmp(&a.priority);
380        if pri_cmp != std::cmp::Ordering::Equal {
381            return pri_cmp;
382        }
383
384        // Secondary sort: location preference (if specified and non-empty)
385        if !location_preference.is_empty() {
386            let a_matches = a
387                .location
388                .as_ref()
389                .map(|l| {
390                    l.contains(location_preference) || location_preference.contains(l.as_str())
391                })
392                .unwrap_or(false);
393            let b_matches = b
394                .location
395                .as_ref()
396                .map(|l| {
397                    l.contains(location_preference) || location_preference.contains(l.as_str())
398                })
399                .unwrap_or(false);
400
401            // Prefer matching location when priorities are equal
402            if a_matches != b_matches {
403                return b_matches.cmp(&a_matches);
404            }
405        }
406
407        std::cmp::Ordering::Equal
408    });
409
410    sorted
411}
412
413/// Try mirrors in priority order until one succeeds or all fail.
414///
415/// Iterates through sorted URL entries attempting download with each.
416/// Returns immediately on first success, or error after all attempts fail.
417///
418/// This implements automatic mirror failover for improved reliability:
419/// - Logs each attempt with index and URL
420/// - Continues to next mirror on failure
421/// - Returns downloaded data from first successful mirror
422/// - Aggregates errors if all mirrors fail
423///
424/// # Type Parameters
425///
426/// * `F` - Download function type that takes URL string and returns future
427/// * `Fut` - Future type returned by download function
428///
429/// # Arguments
430///
431/// * `sorted_urls` - Slice of URL references in priority order (first = highest priority)
432/// * `download_fn` - Async function that attempts download from a single URL
433///
434/// # Returns
435///
436/// * `Ok(Vec<u8>)` - Downloaded data from first successful mirror
437/// * `Err(String)` - Error message if all mirrors failed
438///
439/// # Example
440///
441/// ```ignore
442/// use aria2_core::engine::metalink_download_command::try_mirrors_with_failover;
443///
444/// async fn download(url: &str) -> Result<Vec<u8>, String> {
445///     // HTTP GET implementation
446/// }
447///
448/// let mirrors = vec![&url_entry1, &url_entry2];
449/// match try_mirrors_with_failover(&mirrors, &download).await {
450///     Ok(data) => println!("Downloaded {} bytes", data.len()),
451///     Err(e) => println!("All mirrors failed: {}", e),
452/// }
453/// ```
454pub async fn try_mirrors_with_failover<F, Fut>(
455    sorted_urls: &[&UrlEntry],
456    download_fn: F,
457) -> std::result::Result<Vec<u8>, String>
458where
459    F: Fn(&str) -> Fut,
460    Fut: std::future::Future<Output = std::result::Result<Vec<u8>, String>>,
461{
462    for (i, url_res) in sorted_urls.iter().enumerate() {
463        info!(
464            index = i,
465            url = %url_res.url,
466            priority = url_res.priority,
467            "Trying mirror"
468        );
469
470        match download_fn(&url_res.url).await {
471            Ok(data) => {
472                info!(
473                    index = i,
474                    size = data.len(),
475                    url = %url_res.url,
476                    "Mirror succeeded"
477                );
478                return Ok(data);
479            }
480            Err(e) => {
481                warn!(
482                    index = i,
483                    url = %url_res.url,
484                    error = %e,
485                    "Mirror failed, trying next"
486                );
487                continue;
488            }
489        }
490    }
491
492    Err(format!("All {} mirrors failed", sorted_urls.len()))
493}
494
495// =========================================================================
496// K3.3 — Tests for Metalink Priority Ordering
497// =========================================================================
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use aria2_protocol::metalink::parser::UrlEntry;
503
504    /// Test K3.3 #1: Priority descending order works correctly.
505    ///
506    /// Verifies that URLs are sorted by priority in descending order
507    /// (higher priority number = tried first).
508    #[test]
509    fn test_priority_descending_order() {
510        let urls = vec![
511            UrlEntry::new("http://mirror3.example.com/file.bin").with_priority(1),
512            UrlEntry::new("http://mirror1.example.com/file.bin").with_priority(3),
513            UrlEntry::new("http://mirror2.example.com/file.bin").with_priority(2),
514        ];
515
516        let sorted = select_mirrors_by_priority(&urls, "");
517
518        // Should be ordered by priority descending: [3, 2, 1]
519        assert_eq!(sorted.len(), 3, "Should return all URLs");
520        assert_eq!(
521            sorted[0].priority, 3,
522            "First URL should have highest priority (3)"
523        );
524        assert_eq!(
525            sorted[1].priority, 2,
526            "Second URL should have medium priority (2)"
527        );
528        assert_eq!(
529            sorted[2].priority, 1,
530            "Third URL should have lowest priority (1)"
531        );
532
533        // Verify URL ordering matches priority
534        assert!(
535            sorted[0].url.contains("mirror1"),
536            "First should be mirror1 (priority 3)"
537        );
538        assert!(
539            sorted[1].url.contains("mirror2"),
540            "Second should be mirror2 (priority 2)"
541        );
542        assert!(
543            sorted[2].url.contains("mirror3"),
544            "Third should be mirror3 (priority 1)"
545        );
546    }
547
548    /// Test K3.3 #2: Location preference boosts matching URLs among same priority.
549    ///
550    /// When multiple URLs have the same priority, those matching the location
551    /// preference should be tried first.
552    #[test]
553    fn test_location_preference_boosts_matching() {
554        let urls = vec![
555            UrlEntry::new("http://us-mirror1.example.com/file.bin")
556                .with_priority(5)
557                .with_location("us"),
558            UrlEntry::new("http://eu-mirror1.example.com/file.bin")
559                .with_priority(5)
560                .with_location("eu"),
561            UrlEntry::new("http://eu-mirror2.example.com/file.bin")
562                .with_priority(5)
563                .with_location("eu"),
564            UrlEntry::new("http://jp-mirror1.example.com/file.bin")
565                .with_priority(5)
566                .with_location("jp"),
567        ];
568
569        // Prefer EU locations
570        let sorted = select_mirrors_by_priority(&urls, "eu");
571
572        assert_eq!(sorted.len(), 4, "Should return all URLs");
573
574        // All have same priority (5), so EU ones should come first
575        let eu_urls: Vec<_> = sorted
576            .iter()
577            .filter(|u| u.location.as_deref() == Some("eu"))
578            .collect();
579
580        assert_eq!(eu_urls.len(), 2, "Should find 2 EU mirrors");
581
582        // EU mirrors should appear before non-EU mirrors
583        let first_non_eu_idx = sorted
584            .iter()
585            .position(|u| u.location.as_deref() != Some("eu"))
586            .expect("Should find at least one non-EU mirror");
587
588        let last_eu_idx = sorted
589            .iter()
590            .rposition(|u| u.location.as_deref() == Some("eu"))
591            .expect("Should find EU mirrors");
592
593        assert!(
594            last_eu_idx < first_non_eu_idx,
595            "EU mirrors should come before non-EU mirrors"
596        );
597
598        // Test with US preference
599        let sorted_us = select_mirrors_by_priority(&urls, "us");
600        let us_first = &sorted_us[0];
601        assert_eq!(
602            us_first.location.as_deref(),
603            Some("us"),
604            "US mirror should be first when preferring US"
605        );
606    }
607
608    /// Test K3.3 #3: Failover tries all mirrors then returns error when all fail.
609    ///
610    /// Verifies that try_mirrors_with_failover attempts every mirror and
611    /// returns an error message when all attempts fail.
612    #[tokio::test]
613    async fn test_failover_tries_all_then_errors() {
614        let urls = [
615            UrlEntry::new("http://mirror1.fail/file.bin").with_priority(3),
616            UrlEntry::new("http://mirror2.fail/file.bin").with_priority(2),
617            UrlEntry::new("http://mirror3.fail/file.bin").with_priority(1),
618        ];
619
620        // Download function that always fails
621        let fail_fn = |url: &str| -> std::pin::Pin<
622            Box<dyn std::future::Future<Output = std::result::Result<Vec<u8>, String>> + '_>,
623        > {
624            let url_owned = url.to_string();
625            Box::pin(async move { Err(format!("Connection refused to {}", url_owned)) })
626        };
627
628        let url_refs: Vec<&UrlEntry> = urls.iter().collect();
629
630        let result = try_mirrors_with_failover(&url_refs, fail_fn).await;
631
632        assert!(result.is_err(), "Should return error when all mirrors fail");
633
634        let error_msg = result.unwrap_err();
635        assert!(
636            error_msg.contains("All 3 mirrors failed"),
637            "Error message should indicate all 3 mirrors failed"
638        );
639    }
640
641    /// Test K3.3 #4: Single mirror succeeds immediately without failover.
642    ///
643    /// Verifies that when there's only one mirror and it succeeds,
644    /// the data is returned without attempting additional failover.
645    #[tokio::test]
646    async fn test_single_mirror_no_failover_needed() {
647        let urls =
648            [UrlEntry::new("http://working-mirror.example.com/success.bin").with_priority(10)];
649
650        let expected_data = b"Downloaded file content".to_vec();
651
652        // Download function that succeeds immediately
653        // Use Arc so data can be cloned multiple times (Fn trait requirement)
654        let data_shared = std::sync::Arc::new(expected_data.clone());
655        let success_fn = move |_url: &str| {
656            let data = data_shared.clone();
657            async move { Ok((*data).clone()) }
658        };
659
660        let result = try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &success_fn).await;
661
662        assert!(result.is_ok(), "Single working mirror should succeed");
663
664        let downloaded_data = result.unwrap();
665        assert_eq!(
666            downloaded_data, expected_data,
667            "Downloaded data should match expected content"
668        );
669        assert_eq!(
670            downloaded_data.len(),
671            expected_data.len(),
672            "Should download exactly {} bytes",
673            expected_data.len()
674        );
675    }
676
677    /// Additional test: Mixed priorities with location preference.
678    ///
679    /// Verifies that primary sort (priority) takes precedence over secondary
680    /// sort (location). A higher-priority non-matching URL should still come
681    /// before a lower-priority matching URL.
682    #[test]
683    fn test_priority_overrides_location() {
684        let urls = vec![
685            UrlEntry::new("http://low-eu.example.com/file.bin")
686                .with_priority(1)
687                .with_location("eu"), // Low priority but matches location
688            UrlEntry::new("http://high-us.example.com/file.bin")
689                .with_priority(10)
690                .with_location("us"), // High priority but doesn't match
691        ];
692
693        let sorted = select_mirrors_by_priority(&urls, "eu");
694
695        // High priority (10) should come first even though it doesn't match location
696        assert_eq!(
697            sorted[0].priority, 10,
698            "Higher priority URL should come first regardless of location match"
699        );
700        assert_eq!(
701            sorted[0].url, "http://high-us.example.com/file.bin",
702            "First should be high-priority US URL"
703        );
704        assert_eq!(
705            sorted[1].priority, 1,
706            "Lower priority URL should come second"
707        );
708    }
709
710    /// Additional test: Empty resource list returns empty result.
711    #[test]
712    fn test_empty_resources_returns_empty() {
713        let urls: Vec<UrlEntry> = Vec::new();
714        let sorted = select_mirrors_by_priority(&urls, "");
715
716        assert!(sorted.is_empty(), "Empty input should produce empty output");
717    }
718
719    /// Additional test: Failover succeeds on second attempt after first fails.
720    #[tokio::test]
721    async fn test_failover_succeeds_on_second_mirror() {
722        let urls = [
723            UrlEntry::new("http://failing-mirror.example.com/file.bin").with_priority(5),
724            UrlEntry::new("http://working-mirror.example.com/file.bin").with_priority(3),
725        ];
726
727        let attempt_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
728        let count_clone = attempt_count.clone();
729        let fallback_fn = move |url: &str| {
730            let url_owned = url.to_string();
731            let count = count_clone.clone();
732            async move {
733                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
734                if url_owned.contains("failing") {
735                    Err("Connection timeout".to_string())
736                } else {
737                    Ok(b"Success data".to_vec())
738                }
739            }
740        };
741
742        let result =
743            try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &fallback_fn).await;
744
745        assert!(result.is_ok(), "Should succeed on second mirror");
746        assert_eq!(
747            attempt_count.load(std::sync::atomic::Ordering::SeqCst),
748            2,
749            "Should have attempted 2 mirrors"
750        );
751
752        let data = result.unwrap();
753        assert_eq!(
754            data, b"Success data",
755            "Data from second mirror should be returned"
756        );
757    }
758}