tact_client/
http.rs

1//! HTTP client for TACT protocol
2
3use crate::{CdnEntry, Error, Region, Result, VersionEntry, response_types};
4use reqwest::{Client, Response};
5use std::time::Duration;
6use tokio::time::sleep;
7use tracing::{debug, trace, warn};
8
9/// Default maximum retries (0 = no retries, maintains backward compatibility)
10const DEFAULT_MAX_RETRIES: u32 = 0;
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/// TACT protocol version
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ProtocolVersion {
27    /// Version 1: HTTP-based protocol on port 1119
28    V1,
29    /// Version 2: HTTPS-based REST API
30    V2,
31}
32
33/// HTTP client for TACT protocol
34#[derive(Debug, Clone)]
35pub struct HttpClient {
36    client: Client,
37    region: Region,
38    version: ProtocolVersion,
39    max_retries: u32,
40    initial_backoff_ms: u64,
41    max_backoff_ms: u64,
42    backoff_multiplier: f64,
43    jitter_factor: f64,
44    user_agent: Option<String>,
45}
46
47impl HttpClient {
48    /// Create a new HTTP client for the specified region and protocol version
49    pub fn new(region: Region, version: ProtocolVersion) -> Result<Self> {
50        let client = Client::builder().timeout(Duration::from_secs(30)).build()?;
51
52        Ok(Self {
53            client,
54            region,
55            version,
56            max_retries: DEFAULT_MAX_RETRIES,
57            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
58            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
59            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
60            jitter_factor: DEFAULT_JITTER_FACTOR,
61            user_agent: None,
62        })
63    }
64
65    /// Create a new HTTP client with custom reqwest client
66    pub fn with_client(client: Client, region: Region, version: ProtocolVersion) -> Self {
67        Self {
68            client,
69            region,
70            version,
71            max_retries: DEFAULT_MAX_RETRIES,
72            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
73            max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
74            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
75            jitter_factor: DEFAULT_JITTER_FACTOR,
76            user_agent: None,
77        }
78    }
79
80    /// Set the maximum number of retries for failed requests
81    ///
82    /// Default is 0 (no retries) to maintain backward compatibility.
83    /// Only network and connection errors are retried, not parsing errors.
84    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
85        self.max_retries = max_retries;
86        self
87    }
88
89    /// Set the initial backoff duration in milliseconds
90    ///
91    /// Default is 100ms. This is the base delay before the first retry.
92    pub fn with_initial_backoff_ms(mut self, initial_backoff_ms: u64) -> Self {
93        self.initial_backoff_ms = initial_backoff_ms;
94        self
95    }
96
97    /// Set the maximum backoff duration in milliseconds
98    ///
99    /// Default is 10,000ms (10 seconds). Backoff will not exceed this value.
100    pub fn with_max_backoff_ms(mut self, max_backoff_ms: u64) -> Self {
101        self.max_backoff_ms = max_backoff_ms;
102        self
103    }
104
105    /// Set the backoff multiplier
106    ///
107    /// Default is 2.0. The backoff duration is multiplied by this value after each retry.
108    pub fn with_backoff_multiplier(mut self, backoff_multiplier: f64) -> Self {
109        self.backoff_multiplier = backoff_multiplier;
110        self
111    }
112
113    /// Set the jitter factor (0.0 to 1.0)
114    ///
115    /// Default is 0.1 (10% jitter). Adds randomness to prevent thundering herd.
116    pub fn with_jitter_factor(mut self, jitter_factor: f64) -> Self {
117        self.jitter_factor = jitter_factor.clamp(0.0, 1.0);
118        self
119    }
120
121    /// Set a custom user agent string
122    ///
123    /// If not set, reqwest's default user agent will be used.
124    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
125        self.user_agent = Some(user_agent.into());
126        self
127    }
128
129    /// Get the base URL for the current configuration
130    pub fn base_url(&self) -> String {
131        match self.version {
132            ProtocolVersion::V1 => {
133                format!("http://{}.patch.battle.net:1119", self.region)
134            }
135            ProtocolVersion::V2 => {
136                format!("https://{}.version.battle.net/v2/products", self.region)
137            }
138        }
139    }
140
141    /// Get the current region
142    pub fn region(&self) -> Region {
143        self.region
144    }
145
146    /// Get the current protocol version
147    pub fn version(&self) -> ProtocolVersion {
148        self.version
149    }
150
151    /// Set the region
152    pub fn set_region(&mut self, region: Region) {
153        self.region = region;
154    }
155
156    /// Calculate backoff duration with exponential backoff and jitter
157    #[allow(
158        clippy::cast_precision_loss,
159        clippy::cast_possible_wrap,
160        clippy::cast_possible_truncation,
161        clippy::cast_sign_loss
162    )]
163    fn calculate_backoff(&self, attempt: u32) -> Duration {
164        let base_backoff =
165            self.initial_backoff_ms as f64 * self.backoff_multiplier.powi(attempt as i32);
166        let capped_backoff = base_backoff.min(self.max_backoff_ms as f64);
167
168        // Add jitter
169        let jitter_range = capped_backoff * self.jitter_factor;
170        let jitter = rand::random::<f64>() * 2.0 * jitter_range - jitter_range;
171        let final_backoff = (capped_backoff + jitter).max(0.0) as u64;
172
173        Duration::from_millis(final_backoff)
174    }
175
176    /// Execute an HTTP request with retry logic
177    async fn execute_with_retry(&self, url: &str) -> Result<Response> {
178        let mut last_error = None;
179
180        for attempt in 0..=self.max_retries {
181            if attempt > 0 {
182                let backoff = self.calculate_backoff(attempt - 1);
183                debug!("Retry attempt {} after {:?} backoff", attempt, backoff);
184                sleep(backoff).await;
185            }
186
187            debug!("HTTP request to {} (attempt {})", url, attempt + 1);
188
189            let mut request = self.client.get(url);
190            if let Some(ref user_agent) = self.user_agent {
191                request = request.header("User-Agent", user_agent);
192            }
193
194            match request.send().await {
195                Ok(response) => {
196                    trace!("Response status: {}", response.status());
197
198                    // Check if we should retry based on status code
199                    let status = response.status();
200                    if (status.is_server_error()
201                        || status == reqwest::StatusCode::TOO_MANY_REQUESTS)
202                        && attempt < self.max_retries
203                    {
204                        warn!(
205                            "Request returned {} (attempt {}): will retry",
206                            status,
207                            attempt + 1
208                        );
209                        last_error = Some(Error::InvalidResponse);
210                        continue;
211                    }
212
213                    return Ok(response);
214                }
215                Err(e) => {
216                    // Check if error is retryable
217                    let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
218
219                    if is_retryable && attempt < self.max_retries {
220                        warn!(
221                            "Request failed (attempt {}): {}, will retry",
222                            attempt + 1,
223                            e
224                        );
225                        last_error = Some(Error::Http(e));
226                    } else {
227                        // Non-retryable error or final attempt
228                        debug!(
229                            "Request failed (attempt {}): {}, not retrying",
230                            attempt + 1,
231                            e
232                        );
233                        return Err(Error::Http(e));
234                    }
235                }
236            }
237        }
238
239        // This should only be reached if all retries failed
240        Err(last_error.unwrap_or(Error::InvalidResponse))
241    }
242
243    /// Get versions manifest for a product (V1 protocol)
244    pub async fn get_versions(&self, product: &str) -> Result<Response> {
245        if self.version != ProtocolVersion::V1 {
246            return Err(Error::InvalidProtocolVersion);
247        }
248
249        let url = format!("{}/{}/versions", self.base_url(), product);
250        self.execute_with_retry(&url).await
251    }
252
253    /// Get CDN configuration for a product (V1 protocol)
254    pub async fn get_cdns(&self, product: &str) -> Result<Response> {
255        if self.version != ProtocolVersion::V1 {
256            return Err(Error::InvalidProtocolVersion);
257        }
258
259        let url = format!("{}/{}/cdns", self.base_url(), product);
260        self.execute_with_retry(&url).await
261    }
262
263    /// Get BGDL manifest for a product (V1 protocol)
264    pub async fn get_bgdl(&self, product: &str) -> Result<Response> {
265        if self.version != ProtocolVersion::V1 {
266            return Err(Error::InvalidProtocolVersion);
267        }
268
269        let url = format!("{}/{}/bgdl", self.base_url(), product);
270        self.execute_with_retry(&url).await
271    }
272
273    /// Get product summary (V2 protocol)
274    pub async fn get_summary(&self) -> Result<Response> {
275        if self.version != ProtocolVersion::V2 {
276            return Err(Error::InvalidProtocolVersion);
277        }
278
279        let url = self.base_url();
280        self.execute_with_retry(&url).await
281    }
282
283    /// Get product details (V2 protocol)
284    pub async fn get_product(&self, product: &str) -> Result<Response> {
285        if self.version != ProtocolVersion::V2 {
286            return Err(Error::InvalidProtocolVersion);
287        }
288
289        let url = format!("{}/{}", self.base_url(), product);
290        self.execute_with_retry(&url).await
291    }
292
293    /// Make a raw GET request to a path
294    pub async fn get(&self, path: &str) -> Result<Response> {
295        let url = if path.starts_with('/') {
296            format!("{}{}", self.base_url(), path)
297        } else {
298            format!("{}/{}", self.base_url(), path)
299        };
300
301        self.execute_with_retry(&url).await
302    }
303
304    /// Download a file from CDN
305    pub async fn download_file(&self, cdn_host: &str, path: &str, hash: &str) -> Result<Response> {
306        let url = format!(
307            "http://{}/{}/{}/{}/{}",
308            cdn_host,
309            path,
310            &hash[0..2],
311            &hash[2..4],
312            hash
313        );
314
315        // Use execute_with_retry for CDN downloads as well
316        let response = self.execute_with_retry(&url).await?;
317
318        if response.status() == reqwest::StatusCode::NOT_FOUND {
319            return Err(Error::file_not_found(hash));
320        }
321
322        Ok(response)
323    }
324
325    /// Get parsed versions manifest for a product
326    pub async fn get_versions_parsed(&self, product: &str) -> Result<Vec<VersionEntry>> {
327        let response = self.get_versions(product).await?;
328        let text = response.text().await?;
329        response_types::parse_versions(&text)
330    }
331
332    /// Get parsed CDN manifest for a product
333    pub async fn get_cdns_parsed(&self, product: &str) -> Result<Vec<CdnEntry>> {
334        let response = self.get_cdns(product).await?;
335        let text = response.text().await?;
336        response_types::parse_cdns(&text)
337    }
338
339    /// Get parsed BGDL manifest for a product
340    pub async fn get_bgdl_parsed(&self, product: &str) -> Result<Vec<response_types::BgdlEntry>> {
341        let response = self.get_bgdl(product).await?;
342        let text = response.text().await?;
343        response_types::parse_bgdl(&text)
344    }
345}
346
347impl Default for HttpClient {
348    fn default() -> Self {
349        Self::new(Region::US, ProtocolVersion::V2).expect("Failed to create default HTTP client")
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_base_url_v1() {
359        let client = HttpClient::new(Region::US, ProtocolVersion::V1).unwrap();
360        assert_eq!(client.base_url(), "http://us.patch.battle.net:1119");
361
362        let client = HttpClient::new(Region::EU, ProtocolVersion::V1).unwrap();
363        assert_eq!(client.base_url(), "http://eu.patch.battle.net:1119");
364    }
365
366    #[test]
367    fn test_base_url_v2() {
368        let client = HttpClient::new(Region::US, ProtocolVersion::V2).unwrap();
369        assert_eq!(
370            client.base_url(),
371            "https://us.version.battle.net/v2/products"
372        );
373
374        let client = HttpClient::new(Region::EU, ProtocolVersion::V2).unwrap();
375        assert_eq!(
376            client.base_url(),
377            "https://eu.version.battle.net/v2/products"
378        );
379    }
380
381    #[test]
382    fn test_region_setting() {
383        let mut client = HttpClient::new(Region::US, ProtocolVersion::V1).unwrap();
384        assert_eq!(client.region(), Region::US);
385
386        client.set_region(Region::EU);
387        assert_eq!(client.region(), Region::EU);
388        assert_eq!(client.base_url(), "http://eu.patch.battle.net:1119");
389    }
390
391    #[test]
392    fn test_retry_configuration() {
393        let client = HttpClient::new(Region::US, ProtocolVersion::V1)
394            .unwrap()
395            .with_max_retries(3)
396            .with_initial_backoff_ms(200)
397            .with_max_backoff_ms(5000)
398            .with_backoff_multiplier(1.5)
399            .with_jitter_factor(0.2);
400
401        assert_eq!(client.max_retries, 3);
402        assert_eq!(client.initial_backoff_ms, 200);
403        assert_eq!(client.max_backoff_ms, 5000);
404        assert_eq!(client.backoff_multiplier, 1.5);
405        assert_eq!(client.jitter_factor, 0.2);
406    }
407
408    #[test]
409    fn test_jitter_factor_clamping() {
410        let client1 = HttpClient::new(Region::US, ProtocolVersion::V1)
411            .unwrap()
412            .with_jitter_factor(1.5);
413        assert_eq!(client1.jitter_factor, 1.0); // Should be clamped to 1.0
414
415        let client2 = HttpClient::new(Region::US, ProtocolVersion::V1)
416            .unwrap()
417            .with_jitter_factor(-0.5);
418        assert_eq!(client2.jitter_factor, 0.0); // Should be clamped to 0.0
419    }
420
421    #[test]
422    fn test_backoff_calculation() {
423        let client = HttpClient::new(Region::US, ProtocolVersion::V1)
424            .unwrap()
425            .with_initial_backoff_ms(100)
426            .with_max_backoff_ms(1000)
427            .with_backoff_multiplier(2.0)
428            .with_jitter_factor(0.0); // No jitter for predictable test
429
430        // Test exponential backoff
431        let backoff0 = client.calculate_backoff(0);
432        assert_eq!(backoff0.as_millis(), 100); // 100ms * 2^0 = 100ms
433
434        let backoff1 = client.calculate_backoff(1);
435        assert_eq!(backoff1.as_millis(), 200); // 100ms * 2^1 = 200ms
436
437        let backoff2 = client.calculate_backoff(2);
438        assert_eq!(backoff2.as_millis(), 400); // 100ms * 2^2 = 400ms
439
440        // Test max backoff capping
441        let backoff5 = client.calculate_backoff(5);
442        assert_eq!(backoff5.as_millis(), 1000); // Would be 3200ms but capped at 1000ms
443    }
444
445    #[test]
446    fn test_default_retry_configuration() {
447        let client = HttpClient::new(Region::US, ProtocolVersion::V1).unwrap();
448        assert_eq!(client.max_retries, 0); // Default should be 0 for backward compatibility
449    }
450
451    #[test]
452    fn test_user_agent_configuration() {
453        let client = HttpClient::new(Region::US, ProtocolVersion::V1)
454            .unwrap()
455            .with_user_agent("MyCustomAgent/1.0");
456
457        assert_eq!(client.user_agent, Some("MyCustomAgent/1.0".to_string()));
458    }
459
460    #[test]
461    fn test_user_agent_default_none() {
462        let client = HttpClient::new(Region::US, ProtocolVersion::V1).unwrap();
463        assert!(client.user_agent.is_none());
464    }
465}