1use crate::{Error, Result};
4use reqwest::{Client, Response};
5use std::time::Duration;
6use tokio::time::sleep;
7use tracing::{debug, info, trace, warn};
8
9const DEFAULT_MAX_RETRIES: u32 = 3;
11
12const DEFAULT_INITIAL_BACKOFF_MS: u64 = 100;
14
15const DEFAULT_MAX_BACKOFF_MS: u64 = 10_000;
17
18const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
20
21const DEFAULT_JITTER_FACTOR: f64 = 0.1;
23
24const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
26
27const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300;
29
30const DEFAULT_FALLBACK_HOSTS: &[&str] = &["cdn.arctium.tools", "tact.mirror.reliquaryhq.com"];
32
33#[derive(Debug)]
35pub struct CdnClient {
36 client: Client,
38 tact_client: Option<tact_client::HttpClient>,
40 request_batcher: std::sync::Arc<tokio::sync::Mutex<Option<tact_client::RequestBatcher>>>,
42 max_retries: u32,
44 initial_backoff_ms: u64,
46 max_backoff_ms: u64,
48 backoff_multiplier: f64,
50 jitter_factor: f64,
52 user_agent: Option<String>,
54 primary_hosts: std::sync::Arc<parking_lot::RwLock<Vec<String>>>,
56 fallback_hosts: std::sync::Arc<parking_lot::RwLock<Vec<String>>>,
58}
59
60impl CdnClient {
61 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) .gzip(true) .deflate(true) .build()?;
70
71 Ok(Self {
72 client,
73 tact_client: None, request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), 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 pub fn with_client(client: Client) -> Self {
93 Self {
94 client,
95 tact_client: None, request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), 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 pub fn builder() -> CdnClientBuilder {
115 CdnClientBuilder::new()
116 }
117
118 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 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 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 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 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 pub fn clear_primary_hosts(&self) {
171 self.primary_hosts.write().clear();
172 }
173
174 pub fn clear_fallback_hosts(&self) {
176 self.fallback_hosts.write().clear();
177 }
178
179 pub fn disable_fallbacks(&self) {
181 self.clear_fallback_hosts();
182 }
183
184 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 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
193 self.max_retries = max_retries;
194 self
195 }
196
197 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 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 pub fn with_backoff_multiplier(mut self, backoff_multiplier: f64) -> Self {
211 self.backoff_multiplier = backoff_multiplier;
212 self
213 }
214
215 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 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 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 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 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 let client = reqwest::Client::builder()
264 .pool_max_idle_per_host(50) .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, batch_timeout_ms: 50, max_concurrent_batches: 8, 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 #[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 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 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 let status = response.status();
334
335 if status.is_success() {
337 return Ok(response);
338 }
339
340 if status == reqwest::StatusCode::TOO_MANY_REQUESTS
342 && attempt < self.max_retries
343 {
344 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 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 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 return Err(Error::Http(response.error_for_status().unwrap_err()));
384 }
385 Err(e) => {
386 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 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 Err(last_error.unwrap_or_else(|| Error::invalid_response("All retry attempts failed")))
411 }
412
413 pub async fn request(&self, url: &str) -> Result<Response> {
415 self.execute_with_retry(url).await
416 }
417
418 pub fn build_url(cdn_host: &str, path: &str, hash: &str) -> Result<String> {
423 if hash.len() < 4 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
425 return Err(Error::invalid_hash(hash));
426 }
427
428 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 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), }
456 }
457
458 pub async fn download(&self, cdn_host: &str, path: &str, hash: &str) -> Result<Response> {
460 let mut hosts_to_try = Vec::new();
462
463 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 hosts_to_try.extend(self.primary_hosts.read().clone());
473
474 hosts_to_try.extend(self.fallback_hosts.read().clone());
476
477 if hosts_to_try.is_empty() {
478 let url = Self::build_url(cdn_host, path, hash)?;
480 return self.request(&url).await;
481 }
482
483 let mut last_error = None;
485 for (index, host) in hosts_to_try.iter().enumerate() {
486 match self.check_exists(host, path, hash).await {
488 Ok(true) => {
489 debug!("File {} exists on CDN {}", hash, host);
490
491 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 Err(last_error.unwrap_or_else(|| Error::content_not_found(hash)))
521 }
522
523 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 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 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 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 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 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 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); 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 pub async fn download_batched(
655 &self,
656 cdn_host: &str,
657 path: &str,
658 hashes: &[String],
659 ) -> Vec<Result<Vec<u8>>> {
660 let requests = tact_client::RequestBatcher::create_cdn_requests(cdn_host, path, hashes);
662
663 let batcher_arc = match self.get_or_create_request_batcher().await {
665 Ok(batcher_arc) => batcher_arc,
666 Err(e) => {
667 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 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 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 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 for response in batch_responses {
699 let result = match response.result {
700 Ok(http_response) => {
701 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let tact_client = self.get_or_create_tact_client()?.clone();
1170
1171 Ok(ResumableDownload::new(tact_client, progress))
1172 }
1173
1174 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 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#[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 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 pub fn connect_timeout(mut self, secs: u64) -> Self {
1294 self.connect_timeout_secs = secs;
1295 self
1296 }
1297
1298 pub fn request_timeout(mut self, secs: u64) -> Self {
1300 self.request_timeout_secs = secs;
1301 self
1302 }
1303
1304 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 pub fn max_retries(mut self, retries: u32) -> Self {
1312 self.max_retries = retries;
1313 self
1314 }
1315
1316 pub fn initial_backoff_ms(mut self, ms: u64) -> Self {
1318 self.initial_backoff_ms = ms;
1319 self
1320 }
1321
1322 pub fn max_backoff_ms(mut self, ms: u64) -> Self {
1324 self.max_backoff_ms = ms;
1325 self
1326 }
1327
1328 pub fn backoff_multiplier(mut self, multiplier: f64) -> Self {
1330 self.backoff_multiplier = multiplier;
1331 self
1332 }
1333
1334 pub fn jitter_factor(mut self, factor: f64) -> Self {
1336 self.jitter_factor = factor.clamp(0.0, 1.0);
1337 self
1338 }
1339
1340 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 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, request_batcher: std::sync::Arc::new(tokio::sync::Mutex::new(None)), 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); let backoff0 = client.calculate_backoff(0);
1436 assert_eq!(backoff0.as_millis(), 100); let backoff1 = client.calculate_backoff(1);
1439 assert_eq!(backoff1.as_millis(), 200); let backoff2 = client.calculate_backoff(2);
1442 assert_eq!(backoff2.as_millis(), 400); let backoff5 = client.calculate_backoff(5);
1446 assert_eq!(backoff5.as_millis(), 1000); }
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 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 let results = client
1488 .download_parallel(cdn_host, path, &hashes, Some(2))
1489 .await;
1490
1491 assert_eq!(results.len(), 3);
1493 }
1494}