Skip to main content

ngdp_cdn/
client.rs

1//! CDN client for downloading NGDP content
2
3use crate::{Error, Result};
4use reqwest::{Client, Response};
5use std::time::Duration;
6use tokio::time::sleep;
7use tracing::{debug, info, trace, warn};
8
9/// Default maximum retries (0 = no retries, maintains backward compatibility)
10const DEFAULT_MAX_RETRIES: u32 = 3;
11
12/// Default initial backoff in milliseconds
13const DEFAULT_INITIAL_BACKOFF_MS: u64 = 100;
14
15/// Default maximum backoff in milliseconds
16const DEFAULT_MAX_BACKOFF_MS: u64 = 10_000;
17
18/// Default backoff multiplier
19const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
20
21/// Default jitter factor (0.0 to 1.0)
22const DEFAULT_JITTER_FACTOR: f64 = 0.1;
23
24/// Default connection timeout
25const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
26
27/// Default request timeout
28const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300;
29
30/// Default fallback CDN servers (community mirrors)
31const DEFAULT_FALLBACK_HOSTS: &[&str] = &["cdn.arctium.tools", "tact.mirror.reliquaryhq.com"];
32
33/// CDN client for downloading content with automatic fallback
34#[derive(Debug)]
35pub struct CdnClient {
36    /// HTTP client with connection pooling
37    client: Client,
38    /// Shared TACT client for resumable downloads (reuses connection pool)
39    tact_client: Option<tact_client::HttpClient>,
40    /// Request batcher for HTTP/2 multiplexing
41    request_batcher: std::sync::Arc<tokio::sync::Mutex<Option<tact_client::RequestBatcher>>>,
42    /// Maximum number of retries
43    max_retries: u32,
44    /// Initial backoff duration in milliseconds
45    initial_backoff_ms: u64,
46    /// Maximum backoff duration in milliseconds
47    max_backoff_ms: u64,
48    /// Backoff multiplier
49    backoff_multiplier: f64,
50    /// Jitter factor (0.0 to 1.0)
51    jitter_factor: f64,
52    /// Custom user agent string
53    user_agent: Option<String>,
54    /// Primary CDN hosts (tried first)
55    primary_hosts: std::sync::Arc<parking_lot::RwLock<Vec<String>>>,
56    /// Fallback CDN hosts (tried after primary)
57    fallback_hosts: std::sync::Arc<parking_lot::RwLock<Vec<String>>>,
58}
59
60impl CdnClient {
61    /// Create a new CDN client with default configuration
62    pub fn new() -> Result<Self> {
63        let client = Client::builder()
64            .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
65            .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS))
66            .pool_max_idle_per_host(20) // Increase connection pool size for CDN
67            .gzip(true) // Enable automatic gzip decompression
68            .deflate(true) // Enable automatic deflate decompression
69            .build()?;
70
71        Ok(Self {
72            client,
73            tact_client: None, // Will be initialized on first use
74            request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), // Will be initialized on first use
75            max_retries: DEFAULT_MAX_RETRIES,
76            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
77            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
78            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
79            jitter_factor: DEFAULT_JITTER_FACTOR,
80            user_agent: None,
81            primary_hosts: std::sync::Arc::new(parking_lot::RwLock::new(Vec::new())),
82            fallback_hosts: std::sync::Arc::new(parking_lot::RwLock::new(
83                DEFAULT_FALLBACK_HOSTS
84                    .iter()
85                    .map(|s| s.to_string())
86                    .collect(),
87            )),
88        })
89    }
90
91    /// Create a new CDN client with custom HTTP client
92    pub fn with_client(client: Client) -> Self {
93        Self {
94            client,
95            tact_client: None, // Will be initialized on first use
96            request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), // Will be initialized on first use
97            max_retries: DEFAULT_MAX_RETRIES,
98            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
99            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
100            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
101            jitter_factor: DEFAULT_JITTER_FACTOR,
102            user_agent: None,
103            primary_hosts: std::sync::Arc::new(parking_lot::RwLock::new(Vec::new())),
104            fallback_hosts: std::sync::Arc::new(parking_lot::RwLock::new(
105                DEFAULT_FALLBACK_HOSTS
106                    .iter()
107                    .map(|s| s.to_string())
108                    .collect(),
109            )),
110        }
111    }
112
113    /// Create a builder for configuring the CDN client
114    pub fn builder() -> CdnClientBuilder {
115        CdnClientBuilder::new()
116    }
117
118    /// Add a primary CDN host
119    ///
120    /// Primary hosts are tried before fallback hosts
121    pub fn add_primary_host(&self, host: impl Into<String>) {
122        let mut hosts = self.primary_hosts.write();
123        let host = host.into();
124        if !hosts.contains(&host) {
125            hosts.push(host);
126        }
127    }
128
129    /// Add multiple primary CDN hosts
130    pub fn add_primary_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
131        let mut primary = self.primary_hosts.write();
132        for host in hosts {
133            let host = host.into();
134            if !primary.contains(&host) {
135                primary.push(host);
136            }
137        }
138    }
139
140    /// Set primary CDN hosts, replacing any existing ones
141    pub fn set_primary_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
142        let mut primary = self.primary_hosts.write();
143        primary.clear();
144        for host in hosts {
145            primary.push(host.into());
146        }
147    }
148
149    /// Add a fallback CDN host
150    pub fn add_fallback_host(&self, host: impl Into<String>) {
151        let mut hosts = self.fallback_hosts.write();
152        let host = host.into();
153        if !hosts.contains(&host) {
154            hosts.push(host);
155        }
156    }
157
158    /// Add multiple fallback CDN hosts
159    pub fn add_fallback_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
160        let mut fallback = self.fallback_hosts.write();
161        for host in hosts {
162            let host = host.into();
163            if !fallback.contains(&host) {
164                fallback.push(host);
165            }
166        }
167    }
168
169    /// Clear all primary hosts
170    pub fn clear_primary_hosts(&self) {
171        self.primary_hosts.write().clear();
172    }
173
174    /// Clear all fallback hosts  
175    pub fn clear_fallback_hosts(&self) {
176        self.fallback_hosts.write().clear();
177    }
178
179    /// Disable fallback hosts (only use primary)
180    pub fn disable_fallbacks(&self) {
181        self.clear_fallback_hosts();
182    }
183
184    /// Get all configured hosts (primary first, then fallback)
185    pub fn get_all_hosts(&self) -> Vec<String> {
186        let mut hosts = self.primary_hosts.read().clone();
187        hosts.extend(self.fallback_hosts.read().clone());
188        hosts
189    }
190
191    /// Set the maximum number of retries
192    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
193        self.max_retries = max_retries;
194        self
195    }
196
197    /// Set the initial backoff duration in milliseconds
198    pub fn with_initial_backoff_ms(mut self, initial_backoff_ms: u64) -> Self {
199        self.initial_backoff_ms = initial_backoff_ms;
200        self
201    }
202
203    /// Set the maximum backoff duration in milliseconds
204    pub fn with_max_backoff_ms(mut self, max_backoff_ms: u64) -> Self {
205        self.max_backoff_ms = max_backoff_ms;
206        self
207    }
208
209    /// Set the backoff multiplier
210    pub fn with_backoff_multiplier(mut self, backoff_multiplier: f64) -> Self {
211        self.backoff_multiplier = backoff_multiplier;
212        self
213    }
214
215    /// Set the jitter factor (0.0 to 1.0)
216    pub fn with_jitter_factor(mut self, jitter_factor: f64) -> Self {
217        self.jitter_factor = jitter_factor.clamp(0.0, 1.0);
218        self
219    }
220
221    /// Set a custom user agent string
222    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
223        self.user_agent = Some(user_agent.into());
224        self
225    }
226
227    /// Get or create a shared TACT client for resumable downloads
228    ///
229    /// This reuses the same HttpClient instance across multiple resumable downloads,
230    /// providing better connection pooling performance.
231    fn get_or_create_tact_client(&mut self) -> Result<&tact_client::HttpClient> {
232        if self.tact_client.is_none() {
233            use tact_client::{HttpClient, ProtocolVersion, Region};
234
235            // Create TACT client with shared connection pool
236            let mut tact_client = HttpClient::with_shared_pool(Region::US, ProtocolVersion::V2)
237                .with_max_retries(self.max_retries)
238                .with_initial_backoff_ms(self.initial_backoff_ms);
239
240            if let Some(user_agent) = &self.user_agent {
241                tact_client = tact_client.with_user_agent(user_agent);
242            }
243
244            self.tact_client = Some(tact_client);
245        }
246
247        Ok(self.tact_client.as_ref().unwrap())
248    }
249
250    /// Get or create a request batcher for HTTP/2 multiplexing
251    ///
252    /// This enables efficient batching of multiple CDN requests over HTTP/2 connections,
253    /// significantly improving performance for batch operations.
254    async fn get_or_create_request_batcher(
255        &self,
256    ) -> Result<std::sync::Arc<tokio::sync::Mutex<Option<tact_client::RequestBatcher>>>> {
257        let mut batcher_guard = self.request_batcher.lock().await;
258
259        if batcher_guard.is_none() {
260            use tact_client::{BatchConfig, RequestBatcher};
261
262            // Create HTTP/2 client with optimizations for CDN requests
263            let client = reqwest::Client::builder()
264                // HTTP/2 is automatically negotiated when available
265                .pool_max_idle_per_host(50) // Higher connection pool for batching
266                .pool_idle_timeout(std::time::Duration::from_secs(120))
267                .timeout(std::time::Duration::from_secs(300))
268                .connect_timeout(std::time::Duration::from_secs(30))
269                .use_rustls_tls()
270                .tcp_keepalive(std::time::Duration::from_secs(60))
271                .gzip(true)
272                .deflate(true)
273                .build()
274                .map_err(Error::Http)?;
275
276            let batch_config = BatchConfig {
277                batch_size: 20,            // Optimal for HTTP/2 multiplexing
278                batch_timeout_ms: 50,      // Quick batching for low latency
279                max_concurrent_batches: 8, // Higher concurrency for CDN
280                batch_execution_timeout: std::time::Duration::from_secs(300),
281            };
282
283            let batcher = RequestBatcher::new(client, batch_config);
284            *batcher_guard = Some(batcher);
285        }
286
287        Ok(std::sync::Arc::clone(&self.request_batcher))
288    }
289
290    /// Calculate backoff duration with exponential backoff and jitter
291    #[allow(
292        clippy::cast_precision_loss,
293        clippy::cast_possible_wrap,
294        clippy::cast_possible_truncation,
295        clippy::cast_sign_loss
296    )]
297    pub fn calculate_backoff(&self, attempt: u32) -> Duration {
298        let base_backoff =
299            self.initial_backoff_ms as f64 * self.backoff_multiplier.powi(attempt as i32);
300        let capped_backoff = base_backoff.min(self.max_backoff_ms as f64);
301
302        // Add jitter
303        let jitter_range = capped_backoff * self.jitter_factor;
304        let jitter = rand::random::<f64>() * 2.0 * jitter_range - jitter_range;
305        let final_backoff = (capped_backoff + jitter).max(0.0) as u64;
306
307        Duration::from_millis(final_backoff)
308    }
309
310    /// Execute a request with retry logic
311    async fn execute_with_retry(&self, url: &str) -> Result<Response> {
312        let mut last_error = None;
313
314        for attempt in 0..=self.max_retries {
315            if attempt > 0 {
316                let backoff = self.calculate_backoff(attempt - 1);
317                debug!("CDN retry attempt {} after {:?} backoff", attempt, backoff);
318                sleep(backoff).await;
319            }
320
321            debug!("CDN request to {} (attempt {})", url, attempt + 1);
322
323            let mut request = self.client.get(url);
324            if let Some(ref user_agent) = self.user_agent {
325                request = request.header("User-Agent", user_agent);
326            }
327
328            match request.send().await {
329                Ok(response) => {
330                    trace!("Response status: {}", response.status());
331
332                    // Check if we should retry based on status code
333                    let status = response.status();
334
335                    // Success - return the response
336                    if status.is_success() {
337                        return Ok(response);
338                    }
339
340                    // Check for rate limiting
341                    if status == reqwest::StatusCode::TOO_MANY_REQUESTS
342                        && attempt < self.max_retries
343                    {
344                        // Try to parse Retry-After header
345                        let retry_after = response
346                            .headers()
347                            .get("retry-after")
348                            .and_then(|v| v.to_str().ok())
349                            .and_then(|s| s.parse::<u64>().ok())
350                            .unwrap_or(60);
351
352                        warn!(
353                            "Rate limited (attempt {}): retry after {} seconds",
354                            attempt + 1,
355                            retry_after
356                        );
357                        last_error = Some(Error::rate_limited(retry_after));
358                        continue;
359                    }
360
361                    // Server errors - retry
362                    if status.is_server_error() && attempt < self.max_retries {
363                        warn!(
364                            "Server error {} (attempt {}): will retry",
365                            status,
366                            attempt + 1
367                        );
368                        last_error = Some(Error::Http(response.error_for_status().unwrap_err()));
369                        continue;
370                    }
371
372                    // Client errors - don't retry
373                    if status.is_client_error() {
374                        if status == reqwest::StatusCode::NOT_FOUND {
375                            let parts: Vec<&str> = url.rsplitn(2, '/').collect();
376                            let hash = parts.first().copied().unwrap_or("unknown");
377                            return Err(Error::content_not_found(hash));
378                        }
379                        return Err(Error::Http(response.error_for_status().unwrap_err()));
380                    }
381
382                    // Other errors - don't retry
383                    return Err(Error::Http(response.error_for_status().unwrap_err()));
384                }
385                Err(e) => {
386                    // Check if error is retryable
387                    let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
388
389                    if is_retryable && attempt < self.max_retries {
390                        warn!(
391                            "Request failed (attempt {}): {}, will retry",
392                            attempt + 1,
393                            e
394                        );
395                        last_error = Some(Error::Http(e));
396                    } else {
397                        // Non-retryable error or final attempt
398                        debug!(
399                            "Request failed (attempt {}): {}, not retrying",
400                            attempt + 1,
401                            e
402                        );
403                        return Err(Error::Http(e));
404                    }
405                }
406            }
407        }
408
409        // This should only be reached if all retries failed
410        Err(last_error.unwrap_or_else(|| Error::invalid_response("All retry attempts failed")))
411    }
412
413    /// Make a basic request to a CDN URL
414    pub async fn request(&self, url: &str) -> Result<Response> {
415        self.execute_with_retry(url).await
416    }
417
418    /// Build a CDN URL for a content hash
419    ///
420    /// CDN URLs follow the pattern:
421    /// `http://{cdn_host}/{path}/{hash[0:2]}/{hash[2:4]}/{hash}`
422    pub fn build_url(cdn_host: &str, path: &str, hash: &str) -> Result<String> {
423        // Validate hash format (should be hex)
424        if hash.len() < 4 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
425            return Err(Error::invalid_hash(hash));
426        }
427
428        // Build the URL with the standard CDN path structure
429        let url = format!(
430            "http://{}/{}/{}/{}/{}",
431            cdn_host,
432            path.trim_matches('/'),
433            &hash[0..2],
434            &hash[2..4],
435            hash
436        );
437
438        Ok(url)
439    }
440
441    /// Check if content exists on CDN using HEAD request
442    pub async fn check_exists(&self, cdn_host: &str, path: &str, hash: &str) -> Result<bool> {
443        let url = Self::build_url(cdn_host, path, hash)?;
444
445        debug!("CDN HEAD request to {}", url);
446
447        let mut request = self.client.head(&url);
448        if let Some(ref user_agent) = self.user_agent {
449            request = request.header("User-Agent", user_agent);
450        }
451
452        match request.send().await {
453            Ok(response) => Ok(response.status().is_success()),
454            Err(_) => Ok(false), // Treat network errors as not found
455        }
456    }
457
458    /// Download content from CDN by hash with automatic fallback
459    pub async fn download(&self, cdn_host: &str, path: &str, hash: &str) -> Result<Response> {
460        // Get all hosts to try (primary first, then fallback)
461        let mut hosts_to_try = Vec::new();
462
463        // Add the provided host first if it's not already in our lists
464        let provided_host = cdn_host.to_string();
465        if !self.primary_hosts.read().contains(&provided_host)
466            && !self.fallback_hosts.read().contains(&provided_host)
467        {
468            hosts_to_try.push(provided_host);
469        }
470
471        // Add primary hosts
472        hosts_to_try.extend(self.primary_hosts.read().clone());
473
474        // Add fallback hosts
475        hosts_to_try.extend(self.fallback_hosts.read().clone());
476
477        if hosts_to_try.is_empty() {
478            // If no hosts configured, just use the provided host
479            let url = Self::build_url(cdn_host, path, hash)?;
480            return self.request(&url).await;
481        }
482
483        // Try each host in order
484        let mut last_error = None;
485        for (index, host) in hosts_to_try.iter().enumerate() {
486            // Check if file exists using HEAD request first
487            match self.check_exists(host, path, hash).await {
488                Ok(true) => {
489                    debug!("File {} exists on CDN {}", hash, host);
490
491                    // Try to download
492                    let url = Self::build_url(host, path, hash)?;
493                    match self.request(&url).await {
494                        Ok(response) => {
495                            if index > 0 {
496                                info!(
497                                    "Successfully downloaded from {} (attempt {})",
498                                    host,
499                                    index + 1
500                                );
501                            }
502                            return Ok(response);
503                        }
504                        Err(e) => {
505                            warn!("Failed to download from CDN {}: {}", host, e);
506                            last_error = Some(e);
507                        }
508                    }
509                }
510                Ok(false) => {
511                    debug!("File {} not found on CDN {}", hash, host);
512                }
513                Err(e) => {
514                    debug!("HEAD request failed for CDN {}: {}", host, e);
515                }
516            }
517        }
518
519        // All hosts failed
520        Err(last_error.unwrap_or_else(|| Error::content_not_found(hash)))
521    }
522
523    /// Download BuildConfig from CDN
524    ///
525    /// BuildConfig files are stored at `{path}/config/{hash}`
526    pub async fn download_build_config(
527        &self,
528        cdn_host: &str,
529        path: &str,
530        hash: &str,
531    ) -> Result<Response> {
532        let config_path = format!("{}/config", path.trim_end_matches('/'));
533        self.download(cdn_host, &config_path, hash).await
534    }
535
536    /// Download CDNConfig from CDN
537    ///
538    /// CDNConfig files are stored at `{path}/config/{hash}`
539    pub async fn download_cdn_config(
540        &self,
541        cdn_host: &str,
542        path: &str,
543        hash: &str,
544    ) -> Result<Response> {
545        let config_path = format!("{}/config", path.trim_end_matches('/'));
546        self.download(cdn_host, &config_path, hash).await
547    }
548
549    /// Download ProductConfig from CDN
550    ///
551    /// ProductConfig files are stored at `{config_path}/{hash}`
552    /// Note: This uses the config_path from CDN response, not the regular path
553    pub async fn download_product_config(
554        &self,
555        cdn_host: &str,
556        config_path: &str,
557        hash: &str,
558    ) -> Result<Response> {
559        self.download(cdn_host, config_path, hash).await
560    }
561
562    /// Download KeyRing from CDN
563    ///
564    /// KeyRing files are stored at `{path}/config/{hash}`
565    pub async fn download_key_ring(
566        &self,
567        cdn_host: &str,
568        path: &str,
569        hash: &str,
570    ) -> Result<Response> {
571        let config_path = format!("{}/config", path.trim_end_matches('/'));
572        self.download(cdn_host, &config_path, hash).await
573    }
574
575    /// Download data file from CDN
576    ///
577    /// Data files are stored at `{path}/data/{hash}`
578    pub async fn download_data(&self, cdn_host: &str, path: &str, hash: &str) -> Result<Response> {
579        let data_path = format!("{}/data", path.trim_end_matches('/'));
580        self.download(cdn_host, &data_path, hash).await
581    }
582
583    /// Download patch file from CDN
584    ///
585    /// Patch files are stored at `{path}/patch/{hash}`
586    pub async fn download_patch(&self, cdn_host: &str, path: &str, hash: &str) -> Result<Response> {
587        let patch_path = format!("{}/patch", path.trim_end_matches('/'));
588        self.download(cdn_host, &patch_path, hash).await
589    }
590
591    /// Download multiple files in parallel
592    ///
593    /// Returns a vector of results in the same order as the input hashes.
594    /// Failed downloads will be represented as Err values in the vector.
595    ///
596    /// # Arguments
597    /// * `cdn_host` - CDN host to download from
598    /// * `path` - Base path on the CDN
599    /// * `hashes` - List of content hashes to download
600    /// * `max_concurrent` - Maximum number of concurrent downloads (None = unlimited)
601    pub async fn download_parallel(
602        &self,
603        cdn_host: &str,
604        path: &str,
605        hashes: &[String],
606        max_concurrent: Option<usize>,
607    ) -> Vec<Result<Vec<u8>>> {
608        use futures_util::stream::{self, StreamExt};
609
610        let max_concurrent = max_concurrent.unwrap_or(10); // Default to 10 concurrent downloads
611
612        let futures = hashes.iter().map(|hash| {
613            let cdn_host = cdn_host.to_string();
614            let path = path.to_string();
615
616            async move {
617                match self.download(&cdn_host, &path, hash).await {
618                    Ok(response) => response
619                        .bytes()
620                        .await
621                        .map(|b| b.to_vec())
622                        .map_err(Into::into),
623                    Err(e) => Err(e),
624                }
625            }
626        });
627
628        stream::iter(futures)
629            .buffer_unordered(max_concurrent)
630            .collect()
631            .await
632    }
633
634    /// Download multiple files using HTTP/2 request batching for optimal performance
635    ///
636    /// This method uses HTTP/2 multiplexing to batch requests efficiently, providing
637    /// significant performance improvements over traditional parallel downloads.
638    ///
639    /// # Arguments
640    /// * `cdn_host` - CDN host to download from
641    /// * `path` - Base path on the CDN
642    /// * `hashes` - List of content hashes to download
643    ///
644    /// # Returns
645    /// Vector of results in the same order as input hashes, containing downloaded data or errors.
646    ///
647    /// # Performance
648    /// HTTP/2 multiplexing allows multiple requests over a single connection, reducing:
649    /// - Connection overhead
650    /// - Network latency
651    /// - Server connection pressure
652    ///
653    /// Expected improvement: 2-5x faster than traditional parallel downloads for batches >10 files.
654    pub async fn download_batched(
655        &self,
656        cdn_host: &str,
657        path: &str,
658        hashes: &[String],
659    ) -> Vec<Result<Vec<u8>>> {
660        // Create batch requests for all hashes
661        let requests = tact_client::RequestBatcher::create_cdn_requests(cdn_host, path, hashes);
662
663        // Get the request batcher
664        let batcher_arc = match self.get_or_create_request_batcher().await {
665            Ok(batcher_arc) => batcher_arc,
666            Err(e) => {
667                // Fallback to regular parallel downloads if batching fails
668                warn!(
669                    "Failed to create request batcher, falling back to parallel downloads: {}",
670                    e
671                );
672                return self
673                    .download_parallel(cdn_host, path, hashes, Some(20))
674                    .await;
675            }
676        };
677
678        // Submit batch and wait for responses
679        let batch_responses = {
680            let batcher_guard = batcher_arc.lock().await;
681            if let Some(ref batcher) = *batcher_guard {
682                batcher.submit_requests_and_wait(requests).await
683            } else {
684                // Fallback if batcher wasn't initialized
685                warn!("Request batcher not initialized, falling back to parallel downloads");
686                return self
687                    .download_parallel(cdn_host, path, hashes, Some(20))
688                    .await;
689            }
690        };
691
692        // Convert batch responses to the expected format
693        let mut results = Vec::with_capacity(hashes.len());
694        let mut response_map: std::collections::HashMap<String, Result<Vec<u8>>> =
695            std::collections::HashMap::new();
696
697        // Process batch responses
698        for response in batch_responses {
699            let result = match response.result {
700                Ok(http_response) => {
701                    // Convert HTTP response to bytes
702                    match http_response.bytes().await {
703                        Ok(bytes) => Ok(bytes.to_vec()),
704                        Err(e) => Err(Error::Http(e)),
705                    }
706                }
707                Err(tact_err) => {
708                    // Convert tact_client::Error to ngdp_cdn::Error
709                    match tact_err {
710                        tact_client::Error::Http(reqwest_err) => Err(Error::Http(reqwest_err)),
711                        _ => Err(Error::invalid_response(format!(
712                            "TACT client error: {tact_err}"
713                        ))),
714                    }
715                }
716            };
717
718            response_map.insert(response.request_id, result);
719        }
720
721        // Ensure results are in the same order as input hashes
722        for hash in hashes {
723            if let Some(result) = response_map.remove(hash) {
724                results.push(result);
725            } else {
726                results.push(Err(Error::invalid_response(
727                    "No response received for hash",
728                )));
729            }
730        }
731
732        results
733    }
734
735    /// Download multiple files in parallel with progress tracking
736    ///
737    /// Returns a vector of results in the same order as the input hashes.
738    /// The progress callback is called after each successful download.
739    ///
740    /// # Arguments
741    /// * `cdn_host` - CDN host to download from
742    /// * `path` - Base path on the CDN
743    /// * `hashes` - List of content hashes to download
744    /// * `max_concurrent` - Maximum number of concurrent downloads (None = unlimited)
745    /// * `progress` - Callback function called with (completed_count, total_count) after each download
746    pub async fn download_parallel_with_progress<F>(
747        &self,
748        cdn_host: &str,
749        path: &str,
750        hashes: &[String],
751        max_concurrent: Option<usize>,
752        mut progress: F,
753    ) -> Vec<Result<Vec<u8>>>
754    where
755        F: FnMut(usize, usize),
756    {
757        use futures_util::stream::{self, StreamExt};
758        use std::sync::Arc;
759        use std::sync::atomic::{AtomicUsize, Ordering};
760
761        let max_concurrent = max_concurrent.unwrap_or(10);
762        let total = hashes.len();
763        let completed = Arc::new(AtomicUsize::new(0));
764
765        let futures = hashes.iter().enumerate().map(|(idx, hash)| {
766            let cdn_host = cdn_host.to_string();
767            let path = path.to_string();
768            let hash = hash.clone();
769            let completed = Arc::clone(&completed);
770
771            async move {
772                let result = match self.download(&cdn_host, &path, &hash).await {
773                    Ok(response) => response
774                        .bytes()
775                        .await
776                        .map(|b| b.to_vec())
777                        .map_err(Into::into),
778                    Err(e) => Err(e),
779                };
780
781                // Update progress
782                let count = completed.fetch_add(1, Ordering::SeqCst) + 1;
783
784                (idx, result, count)
785            }
786        });
787
788        let mut results: Vec<Result<Vec<u8>>> = Vec::with_capacity(total);
789        for _ in 0..total {
790            results.push(Err(Error::invalid_response("Not downloaded")));
791        }
792
793        let mut download_stream = stream::iter(futures).buffer_unordered(max_concurrent);
794
795        while let Some((idx, result, count)) = download_stream.next().await {
796            results[idx] = result;
797            progress(count, total);
798        }
799
800        results
801    }
802
803    /// Download multiple data files in parallel
804    pub async fn download_data_parallel(
805        &self,
806        cdn_host: &str,
807        path: &str,
808        hashes: &[String],
809        max_concurrent: Option<usize>,
810    ) -> Vec<Result<Vec<u8>>> {
811        let data_path = format!("{}/data", path.trim_end_matches('/'));
812        self.download_parallel(cdn_host, &data_path, hashes, max_concurrent)
813            .await
814    }
815
816    /// Download multiple config files in parallel
817    pub async fn download_config_parallel(
818        &self,
819        cdn_host: &str,
820        path: &str,
821        hashes: &[String],
822        max_concurrent: Option<usize>,
823    ) -> Vec<Result<Vec<u8>>> {
824        let config_path = format!("{}/config", path.trim_end_matches('/'));
825        self.download_parallel(cdn_host, &config_path, hashes, max_concurrent)
826            .await
827    }
828
829    /// Download multiple patch files in parallel
830    pub async fn download_patch_parallel(
831        &self,
832        cdn_host: &str,
833        path: &str,
834        hashes: &[String],
835        max_concurrent: Option<usize>,
836    ) -> Vec<Result<Vec<u8>>> {
837        let patch_path = format!("{}/patch", path.trim_end_matches('/'));
838        self.download_parallel(cdn_host, &patch_path, hashes, max_concurrent)
839            .await
840    }
841
842    /// Download multiple data files using HTTP/2 request batching (optimized)
843    ///
844    /// This method provides superior performance for downloading multiple data files
845    /// by leveraging HTTP/2 multiplexing to reduce connection overhead and latency.
846    pub async fn download_data_batched(
847        &self,
848        cdn_host: &str,
849        path: &str,
850        hashes: &[String],
851    ) -> Vec<Result<Vec<u8>>> {
852        let data_path = format!("{}/data", path.trim_end_matches('/'));
853        self.download_batched(cdn_host, &data_path, hashes).await
854    }
855
856    /// Download multiple config files using HTTP/2 request batching (optimized)
857    ///
858    /// This method provides superior performance for downloading multiple config files
859    /// by leveraging HTTP/2 multiplexing to reduce connection overhead and latency.
860    pub async fn download_config_batched(
861        &self,
862        cdn_host: &str,
863        path: &str,
864        hashes: &[String],
865    ) -> Vec<Result<Vec<u8>>> {
866        let config_path = format!("{}/config", path.trim_end_matches('/'));
867        self.download_batched(cdn_host, &config_path, hashes).await
868    }
869
870    /// Download multiple patch files using HTTP/2 request batching (optimized)
871    ///
872    /// This method provides superior performance for downloading multiple patch files
873    /// by leveraging HTTP/2 multiplexing to reduce connection overhead and latency.
874    pub async fn download_patch_batched(
875        &self,
876        cdn_host: &str,
877        path: &str,
878        hashes: &[String],
879    ) -> Vec<Result<Vec<u8>>> {
880        let patch_path = format!("{}/patch", path.trim_end_matches('/'));
881        self.download_batched(cdn_host, &patch_path, hashes).await
882    }
883
884    /// Get statistics for request batching performance
885    ///
886    /// Returns detailed metrics about HTTP/2 batching performance including
887    /// batch sizes, timing, and HTTP/2 connection usage.
888    pub async fn get_batch_stats(&self) -> Option<tact_client::BatchStats> {
889        let batcher_guard = self.request_batcher.lock().await;
890        if let Some(ref batcher) = *batcher_guard {
891            Some(batcher.get_stats().await)
892        } else {
893            None
894        }
895    }
896
897    /// Download multiple files in parallel with streaming to writers (memory-efficient)
898    ///
899    /// This method downloads multiple files concurrently while streaming each file
900    /// directly to its corresponding writer. This prevents loading all downloads
901    /// into memory simultaneously, making it suitable for large file batches.
902    ///
903    /// # Arguments
904    /// * `cdn_host` - CDN host to download from
905    /// * `path` - Base path for downloads
906    /// * `hashes` - Vector of file hashes to download
907    /// * `writers` - Vector of writers (must match hashes length)
908    /// * `max_concurrent` - Maximum number of concurrent downloads
909    ///
910    /// # Returns
911    /// Vector of results with bytes written for each file
912    pub async fn download_parallel_to_writers<W>(
913        &self,
914        cdn_host: &str,
915        path: &str,
916        hashes: &[String],
917        writers: Vec<W>,
918        max_concurrent: Option<usize>,
919    ) -> Vec<Result<u64>>
920    where
921        W: tokio::io::AsyncWrite + Unpin + Send + 'static,
922    {
923        use futures_util::stream::{self, StreamExt};
924        use tokio::io::AsyncWriteExt;
925
926        if hashes.len() != writers.len() {
927            return (0..hashes.len())
928                .map(|_| {
929                    Err(Error::invalid_response(
930                        "Hashes and writers length mismatch",
931                    ))
932                })
933                .collect();
934        }
935
936        let max_concurrent = max_concurrent.unwrap_or(10);
937
938        let futures =
939            hashes
940                .iter()
941                .zip(writers.into_iter())
942                .enumerate()
943                .map(|(idx, (hash, mut writer))| {
944                    let cdn_host = cdn_host.to_string();
945                    let path = path.to_string();
946                    let hash = hash.clone();
947
948                    async move {
949                        match self.download(&cdn_host, &path, &hash).await {
950                            Ok(response) => {
951                                let mut stream = response.bytes_stream();
952                                let mut total_bytes = 0u64;
953
954                                use futures_util::StreamExt;
955                                while let Some(chunk) = stream.next().await {
956                                    match chunk {
957                                        Ok(bytes) => {
958                                            if let Err(e) = writer.write_all(&bytes).await {
959                                                return (
960                                                    idx,
961                                                    Err(Error::invalid_response(format!(
962                                                        "Write error: {e}"
963                                                    ))),
964                                                );
965                                            }
966                                            total_bytes += bytes.len() as u64;
967                                        }
968                                        Err(e) => {
969                                            return (idx, Err(Error::from(e)));
970                                        }
971                                    }
972                                }
973
974                                if let Err(e) = writer.flush().await {
975                                    return (
976                                        idx,
977                                        Err(Error::invalid_response(format!("Flush error: {e}"))),
978                                    );
979                                }
980
981                                (idx, Ok(total_bytes))
982                            }
983                            Err(e) => (idx, Err(e)),
984                        }
985                    }
986                });
987
988        // Collect results in original order
989        let mut results: Vec<Result<u64>> = Vec::with_capacity(hashes.len());
990        for _ in 0..hashes.len() {
991            results.push(Err(Error::invalid_response("Not downloaded")));
992        }
993
994        let mut download_stream = stream::iter(futures).buffer_unordered(max_concurrent);
995
996        while let Some((idx, result)) = download_stream.next().await {
997            results[idx] = result;
998        }
999
1000        results
1001    }
1002
1003    /// Download content and stream it to a writer
1004    ///
1005    /// This is useful for large files to avoid loading them entirely into memory.
1006    pub async fn download_streaming<W>(
1007        &self,
1008        cdn_host: &str,
1009        path: &str,
1010        hash: &str,
1011        mut writer: W,
1012    ) -> Result<u64>
1013    where
1014        W: tokio::io::AsyncWrite + Unpin,
1015    {
1016        use futures_util::StreamExt;
1017        use tokio::io::AsyncWriteExt;
1018
1019        let response = self.download(cdn_host, path, hash).await?;
1020        let mut stream = response.bytes_stream();
1021        let mut total_bytes = 0u64;
1022
1023        while let Some(chunk) = stream.next().await {
1024            let chunk = chunk?;
1025            writer
1026                .write_all(&chunk)
1027                .await
1028                .map_err(|e| Error::invalid_response(format!("Write error: {e}")))?;
1029            total_bytes += chunk.len() as u64;
1030        }
1031
1032        writer
1033            .flush()
1034            .await
1035            .map_err(|e| Error::invalid_response(format!("Write error: {e}")))?;
1036        Ok(total_bytes)
1037    }
1038
1039    /// Download content and process it in chunks
1040    ///
1041    /// This allows processing large files without loading them entirely into memory.
1042    /// The callback is called for each chunk received.
1043    pub async fn download_chunked<F>(
1044        &self,
1045        cdn_host: &str,
1046        path: &str,
1047        hash: &str,
1048        mut callback: F,
1049    ) -> Result<u64>
1050    where
1051        F: FnMut(&[u8]) -> Result<()>,
1052    {
1053        use futures_util::StreamExt;
1054
1055        let response = self.download(cdn_host, path, hash).await?;
1056        let mut stream = response.bytes_stream();
1057        let mut total_bytes = 0u64;
1058
1059        while let Some(chunk) = stream.next().await {
1060            let chunk = chunk?;
1061            callback(&chunk)?;
1062            total_bytes += chunk.len() as u64;
1063        }
1064
1065        Ok(total_bytes)
1066    }
1067
1068    /// Create a resumable download for a data file
1069    ///
1070    /// This creates a resumable download that can be paused and resumed from interruption.
1071    /// Progress is saved to disk and the download can survive application crashes.
1072    ///
1073    /// # Arguments
1074    ///
1075    /// * `cdn_host` - CDN server hostname
1076    /// * `path` - Base path on the CDN (e.g., "tpr/wow")
1077    /// * `hash` - Content hash to download
1078    /// * `output_file` - Local file path where content should be saved
1079    ///
1080    /// # Returns
1081    ///
1082    /// Returns a `ResumableDownload` instance that can be used to start, pause, and resume the download.
1083    ///
1084    /// # Example
1085    ///
1086    /// ```no_run
1087    /// use ngdp_cdn::CdnClient;
1088    /// use std::path::PathBuf;
1089    ///
1090    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1091    /// let mut client = CdnClient::new()?;
1092    /// let mut resumable = client.create_resumable_download(
1093    ///     "blzddist1-a.akamaihd.net",
1094    ///     "tpr/wow",
1095    ///     "2e9c1e3b5f5a0c9d9e8f1234567890ab",
1096    ///     &PathBuf::from("game_file.bin")
1097    /// )?;
1098    ///
1099    /// // Start or resume the download
1100    /// resumable.start_or_resume().await?;
1101    ///
1102    /// // Clean up progress file when complete
1103    /// resumable.cleanup_completed().await?;
1104    /// # Ok(())
1105    /// # }
1106    /// ```
1107    pub fn create_resumable_download(
1108        &mut self,
1109        cdn_host: &str,
1110        path: &str,
1111        hash: &str,
1112        output_file: &std::path::Path,
1113    ) -> Result<tact_client::resumable::ResumableDownload> {
1114        use tact_client::resumable::{DownloadProgress, ResumableDownload};
1115
1116        // Get the shared TACT client (creates one if needed)
1117        let tact_client = self.get_or_create_tact_client()?.clone();
1118
1119        let progress = DownloadProgress::new(
1120            hash.to_string(),
1121            cdn_host.to_string(),
1122            path.to_string(),
1123            output_file.to_path_buf(),
1124        );
1125
1126        Ok(ResumableDownload::new(tact_client, progress))
1127    }
1128
1129    /// Resume an existing download from a progress file
1130    ///
1131    /// This method loads an existing progress file and creates a `ResumableDownload`
1132    /// instance that can continue from where the previous download left off.
1133    ///
1134    /// # Arguments
1135    ///
1136    /// * `progress_file` - Path to the `.download` progress file
1137    ///
1138    /// # Returns
1139    ///
1140    /// Returns a `ResumableDownload` instance ready to resume the download.
1141    ///
1142    /// # Example
1143    ///
1144    /// ```no_run
1145    /// use ngdp_cdn::CdnClient;
1146    /// use std::path::PathBuf;
1147    ///
1148    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1149    /// let mut client = CdnClient::new()?;
1150    ///
1151    /// // Resume from existing progress file
1152    /// let mut resumable = client.resume_download(&PathBuf::from("file.bin.download")).await?;
1153    /// resumable.start_or_resume().await?;
1154    /// resumable.cleanup_completed().await?;
1155    /// # Ok(())
1156    /// # }
1157    /// ```
1158    pub async fn resume_download(
1159        &mut self,
1160        progress_file: &std::path::Path,
1161    ) -> Result<tact_client::resumable::ResumableDownload> {
1162        use tact_client::resumable::{DownloadProgress, ResumableDownload};
1163
1164        let progress = DownloadProgress::load_from_file(progress_file)
1165            .await
1166            .map_err(|e| Error::invalid_response(format!("Failed to load progress: {e}")))?;
1167
1168        // Get the shared TACT client (creates one if needed)
1169        let tact_client = self.get_or_create_tact_client()?.clone();
1170
1171        Ok(ResumableDownload::new(tact_client, progress))
1172    }
1173
1174    /// Find all resumable downloads in a directory
1175    ///
1176    /// This is a convenience method that scans a directory for `.download` progress files
1177    /// and returns information about incomplete downloads.
1178    ///
1179    /// # Arguments
1180    ///
1181    /// * `directory` - Directory to scan for progress files
1182    ///
1183    /// # Returns
1184    ///
1185    /// Returns a vector of `DownloadProgress` instances for incomplete downloads.
1186    ///
1187    /// # Example
1188    ///
1189    /// ```no_run
1190    /// use ngdp_cdn::CdnClient;
1191    /// use std::path::PathBuf;
1192    ///
1193    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1194    /// let client = CdnClient::new()?;
1195    ///
1196    /// // Find all resumable downloads in Downloads folder
1197    /// let downloads = client.find_resumable_downloads(&PathBuf::from("Downloads")).await?;
1198    ///
1199    /// for download in downloads {
1200    ///     println!("Found: {} - {}", download.file_hash, download.progress_string());
1201    /// }
1202    /// # Ok(())
1203    /// # }
1204    /// ```
1205    pub async fn find_resumable_downloads(
1206        &self,
1207        directory: &std::path::Path,
1208    ) -> Result<Vec<tact_client::resumable::DownloadProgress>> {
1209        tact_client::resumable::find_resumable_downloads(directory)
1210            .await
1211            .map_err(|e| {
1212                Error::Io(std::io::Error::other(format!(
1213                    "Failed to find resumable downloads: {e}"
1214                )))
1215            })
1216    }
1217
1218    /// Clean up old completed progress files in a directory
1219    ///
1220    /// This method scans for `.download` progress files that are marked as completed
1221    /// and are older than the specified age, then removes them to free up disk space.
1222    ///
1223    /// # Arguments
1224    ///
1225    /// * `directory` - Directory to scan and clean up
1226    /// * `max_age_hours` - Maximum age in hours for completed progress files
1227    ///
1228    /// # Returns
1229    ///
1230    /// Returns the number of files that were cleaned up.
1231    ///
1232    /// # Example
1233    ///
1234    /// ```no_run
1235    /// use ngdp_cdn::CdnClient;
1236    /// use std::path::PathBuf;
1237    ///
1238    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1239    /// let client = CdnClient::new()?;
1240    ///
1241    /// // Clean up progress files older than 24 hours
1242    /// let cleaned = client.cleanup_old_progress_files(&PathBuf::from("Downloads"), 24).await?;
1243    /// println!("Cleaned up {} old progress files", cleaned);
1244    /// # Ok(())
1245    /// # }
1246    /// ```
1247    pub async fn cleanup_old_progress_files(
1248        &self,
1249        directory: &std::path::Path,
1250        max_age_hours: u64,
1251    ) -> Result<usize> {
1252        tact_client::resumable::cleanup_old_progress_files(directory, max_age_hours)
1253            .await
1254            .map_err(|e| {
1255                Error::Io(std::io::Error::other(format!(
1256                    "Failed to cleanup progress files: {e}"
1257                )))
1258            })
1259    }
1260}
1261
1262/// Builder for configuring CDN client
1263#[derive(Debug, Clone)]
1264pub struct CdnClientBuilder {
1265    connect_timeout_secs: u64,
1266    request_timeout_secs: u64,
1267    pool_max_idle_per_host: usize,
1268    max_retries: u32,
1269    initial_backoff_ms: u64,
1270    max_backoff_ms: u64,
1271    backoff_multiplier: f64,
1272    jitter_factor: f64,
1273    user_agent: Option<String>,
1274}
1275
1276impl CdnClientBuilder {
1277    /// Create a new builder with default values
1278    pub fn new() -> Self {
1279        Self {
1280            connect_timeout_secs: DEFAULT_CONNECT_TIMEOUT_SECS,
1281            request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
1282            pool_max_idle_per_host: 20,
1283            max_retries: DEFAULT_MAX_RETRIES,
1284            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
1285            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
1286            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
1287            jitter_factor: DEFAULT_JITTER_FACTOR,
1288            user_agent: None,
1289        }
1290    }
1291
1292    /// Set connection timeout
1293    pub fn connect_timeout(mut self, secs: u64) -> Self {
1294        self.connect_timeout_secs = secs;
1295        self
1296    }
1297
1298    /// Set request timeout
1299    pub fn request_timeout(mut self, secs: u64) -> Self {
1300        self.request_timeout_secs = secs;
1301        self
1302    }
1303
1304    /// Set maximum idle connections per host
1305    pub fn pool_max_idle_per_host(mut self, max: usize) -> Self {
1306        self.pool_max_idle_per_host = max;
1307        self
1308    }
1309
1310    /// Set maximum retries
1311    pub fn max_retries(mut self, retries: u32) -> Self {
1312        self.max_retries = retries;
1313        self
1314    }
1315
1316    /// Set initial backoff in milliseconds
1317    pub fn initial_backoff_ms(mut self, ms: u64) -> Self {
1318        self.initial_backoff_ms = ms;
1319        self
1320    }
1321
1322    /// Set maximum backoff in milliseconds
1323    pub fn max_backoff_ms(mut self, ms: u64) -> Self {
1324        self.max_backoff_ms = ms;
1325        self
1326    }
1327
1328    /// Set backoff multiplier
1329    pub fn backoff_multiplier(mut self, multiplier: f64) -> Self {
1330        self.backoff_multiplier = multiplier;
1331        self
1332    }
1333
1334    /// Set jitter factor (0.0 to 1.0)
1335    pub fn jitter_factor(mut self, factor: f64) -> Self {
1336        self.jitter_factor = factor.clamp(0.0, 1.0);
1337        self
1338    }
1339
1340    /// Set custom user agent string
1341    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
1342        self.user_agent = Some(user_agent.into());
1343        self
1344    }
1345
1346    /// Build the CDN client
1347    pub fn build(self) -> Result<CdnClient> {
1348        let client = Client::builder()
1349            .connect_timeout(Duration::from_secs(self.connect_timeout_secs))
1350            .timeout(Duration::from_secs(self.request_timeout_secs))
1351            .pool_max_idle_per_host(self.pool_max_idle_per_host)
1352            .gzip(true)
1353            .deflate(true)
1354            .build()?;
1355
1356        Ok(CdnClient {
1357            client,
1358            tact_client: None, // Will be initialized on first use
1359            request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), // Will be initialized on first use
1360            max_retries: self.max_retries,
1361            initial_backoff_ms: self.initial_backoff_ms,
1362            max_backoff_ms: self.max_backoff_ms,
1363            backoff_multiplier: self.backoff_multiplier,
1364            jitter_factor: self.jitter_factor,
1365            user_agent: self.user_agent,
1366            primary_hosts: std::sync::Arc::new(parking_lot::RwLock::new(Vec::new())),
1367            fallback_hosts: std::sync::Arc::new(parking_lot::RwLock::new(
1368                DEFAULT_FALLBACK_HOSTS
1369                    .iter()
1370                    .map(|s| s.to_string())
1371                    .collect(),
1372            )),
1373        })
1374    }
1375}
1376
1377impl Default for CdnClientBuilder {
1378    fn default() -> Self {
1379        Self::new()
1380    }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386
1387    #[test]
1388    fn test_client_creation() {
1389        let client = CdnClient::new().unwrap();
1390        assert_eq!(client.max_retries, DEFAULT_MAX_RETRIES);
1391        assert_eq!(client.initial_backoff_ms, DEFAULT_INITIAL_BACKOFF_MS);
1392        assert_eq!(client.max_backoff_ms, DEFAULT_MAX_BACKOFF_MS);
1393    }
1394
1395    #[test]
1396    fn test_builder_configuration() {
1397        let client = CdnClient::builder()
1398            .max_retries(5)
1399            .initial_backoff_ms(200)
1400            .max_backoff_ms(5000)
1401            .backoff_multiplier(1.5)
1402            .jitter_factor(0.2)
1403            .connect_timeout(60)
1404            .request_timeout(600)
1405            .pool_max_idle_per_host(100)
1406            .build()
1407            .unwrap();
1408
1409        assert_eq!(client.max_retries, 5);
1410        assert_eq!(client.initial_backoff_ms, 200);
1411        assert_eq!(client.max_backoff_ms, 5000);
1412        assert!((client.backoff_multiplier - 1.5).abs() < f64::EPSILON);
1413        assert!((client.jitter_factor - 0.2).abs() < f64::EPSILON);
1414    }
1415
1416    #[test]
1417    fn test_jitter_factor_clamping() {
1418        let client1 = CdnClient::new().unwrap().with_jitter_factor(1.5);
1419        assert!((client1.jitter_factor - 1.0).abs() < f64::EPSILON);
1420
1421        let client2 = CdnClient::new().unwrap().with_jitter_factor(-0.5);
1422        assert!((client2.jitter_factor - 0.0).abs() < f64::EPSILON);
1423    }
1424
1425    #[test]
1426    fn test_backoff_calculation() {
1427        let client = CdnClient::new()
1428            .unwrap()
1429            .with_initial_backoff_ms(100)
1430            .with_max_backoff_ms(1000)
1431            .with_backoff_multiplier(2.0)
1432            .with_jitter_factor(0.0); // No jitter for predictable test
1433
1434        // Test exponential backoff
1435        let backoff0 = client.calculate_backoff(0);
1436        assert_eq!(backoff0.as_millis(), 100); // 100ms * 2^0 = 100ms
1437
1438        let backoff1 = client.calculate_backoff(1);
1439        assert_eq!(backoff1.as_millis(), 200); // 100ms * 2^1 = 200ms
1440
1441        let backoff2 = client.calculate_backoff(2);
1442        assert_eq!(backoff2.as_millis(), 400); // 100ms * 2^2 = 400ms
1443
1444        // Test max backoff capping
1445        let backoff5 = client.calculate_backoff(5);
1446        assert_eq!(backoff5.as_millis(), 1000); // Would be 3200ms but capped at 1000ms
1447    }
1448
1449    #[test]
1450    fn test_user_agent_configuration() {
1451        let client = CdnClient::new()
1452            .unwrap()
1453            .with_user_agent("MyNGDPClient/1.0");
1454
1455        assert_eq!(client.user_agent, Some("MyNGDPClient/1.0".to_string()));
1456    }
1457
1458    #[test]
1459    fn test_user_agent_via_builder() {
1460        let client = CdnClient::builder()
1461            .user_agent("MyNGDPClient/2.0")
1462            .build()
1463            .unwrap();
1464
1465        assert_eq!(client.user_agent, Some("MyNGDPClient/2.0".to_string()));
1466    }
1467
1468    #[test]
1469    fn test_user_agent_default_none() {
1470        let client = CdnClient::new().unwrap();
1471        assert!(client.user_agent.is_none());
1472    }
1473
1474    #[tokio::test]
1475    async fn test_parallel_download_ordering() {
1476        // Test that results are returned in the same order as input
1477        let client = CdnClient::new().unwrap();
1478        let cdn_host = "example.com";
1479        let path = "test";
1480        let hashes = [
1481            "hash1".to_string(),
1482            "hash2".to_string(),
1483            "hash3".to_string(),
1484        ];
1485
1486        // This will fail since we don't have a real CDN, but we're testing the API
1487        let results = client
1488            .download_parallel(cdn_host, path, &hashes, Some(2))
1489            .await;
1490
1491        // Should get 3 results in the same order
1492        assert_eq!(results.len(), 3);
1493    }
1494}