1use 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
9const DEFAULT_MAX_RETRIES: u32 = 0;
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
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ProtocolVersion {
27 V1,
29 V2,
31}
32
33#[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 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 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 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
85 self.max_retries = max_retries;
86 self
87 }
88
89 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 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 pub fn with_backoff_multiplier(mut self, backoff_multiplier: f64) -> Self {
109 self.backoff_multiplier = backoff_multiplier;
110 self
111 }
112
113 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 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 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 pub fn region(&self) -> Region {
143 self.region
144 }
145
146 pub fn version(&self) -> ProtocolVersion {
148 self.version
149 }
150
151 pub fn set_region(&mut self, region: Region) {
153 self.region = region;
154 }
155
156 #[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 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 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 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 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 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 Err(last_error.unwrap_or(Error::InvalidResponse))
241 }
242
243 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 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 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 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 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 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 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 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 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 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 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); let client2 = HttpClient::new(Region::US, ProtocolVersion::V1)
416 .unwrap()
417 .with_jitter_factor(-0.5);
418 assert_eq!(client2.jitter_factor, 0.0); }
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); let backoff0 = client.calculate_backoff(0);
432 assert_eq!(backoff0.as_millis(), 100); let backoff1 = client.calculate_backoff(1);
435 assert_eq!(backoff1.as_millis(), 200); let backoff2 = client.calculate_backoff(2);
438 assert_eq!(backoff2.as_millis(), 400); let backoff5 = client.calculate_backoff(5);
442 assert_eq!(backoff5.as_millis(), 1000); }
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); }
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}