use reqwest::{Client, Request, Response, StatusCode}; use serde::de::DeserializeOwned; use std::time::Duration; const APP_NAME: &str = "JellyTau"; const APP_VERSION: &str = "0.1.0"; // Default timeout for requests (10 seconds) const DEFAULT_TIMEOUT_MS: u64 = 10000; // Retry configuration - matches TypeScript exactly const DEFAULT_MAX_RETRIES: u32 = 3; const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff /// HTTP client configuration #[derive(Clone, Debug)] pub struct HttpConfig { pub timeout: Duration, pub max_retries: u32, } impl Default for HttpConfig { fn default() -> Self { Self { timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS), max_retries: DEFAULT_MAX_RETRIES, } } } /// Error classification for retry logic #[derive(Debug, Clone, PartialEq)] pub enum ErrorKind { Network, Authentication, Server, Client, } /// Enhanced HTTP client with retry logic and error classification #[derive(Clone)] pub struct HttpClient { pub(crate) client: Client, // Make accessible within crate for custom requests config: HttpConfig, } impl HttpClient { /// Create a new HTTP client with default configuration pub fn new(config: HttpConfig) -> Result { let client = Client::builder() .timeout(config.timeout) .https_only(true) .build() .map_err(|e| format!("Failed to create HTTP client: {}", e))?; Ok(Self { client, config }) } /// Get device name based on platform fn get_device_name() -> &'static str { #[cfg(target_os = "android")] return "Android"; #[cfg(target_os = "linux")] return "Linux"; #[cfg(target_os = "windows")] return "Windows"; #[cfg(target_os = "macos")] return "macOS"; #[cfg(target_os = "ios")] return "iOS"; #[cfg(not(any( target_os = "android", target_os = "linux", target_os = "windows", target_os = "macos", target_os = "ios" )))] return "Unknown"; } /// Build the X-Emby-Authorization header value pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String { let mut parts = vec![ format!("MediaBrowser Client=\"{}\"", APP_NAME), format!("Version=\"{}\"", APP_VERSION), format!("Device=\"{}\"", Self::get_device_name()), format!("DeviceId=\"{}\"", device_id), ]; if let Some(token) = access_token { parts.push(format!("Token=\"{}\"", token)); } parts.join(", ") } /// Classify an error for retry logic pub fn classify_error(error: &reqwest::Error) -> ErrorKind { // Network errors (connection failures, timeouts, DNS failures) if error.is_timeout() || error.is_connect() { return ErrorKind::Network; } // Check status code if available if let Some(status) = error.status() { if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { return ErrorKind::Authentication; } else if status.is_server_error() { return ErrorKind::Server; } else if status.is_client_error() { return ErrorKind::Client; } } // If no status code, check error message for network-related keywords let error_msg = error.to_string().to_lowercase(); if error_msg.contains("network") || error_msg.contains("connection") || error_msg.contains("timeout") || error_msg.contains("dns") || error_msg.contains("refused") || error_msg.contains("reset") { return ErrorKind::Network; } // Default to client error ErrorKind::Client } /// Check if a request should be retried based on the error pub fn should_retry(error: &reqwest::Error) -> bool { match Self::classify_error(error) { ErrorKind::Network => true, // Retry network errors ErrorKind::Server => true, // Retry 5xx server errors ErrorKind::Authentication => false, // Don't retry 401/403 ErrorKind::Client => false, // Don't retry other 4xx errors } } /// Make a request with automatic retry on network errors pub async fn request_with_retry( &self, request: Request, ) -> Result { let max_retries = self.config.max_retries; let mut last_error: Option = None; for attempt in 0..=max_retries { // Clone the request for retry attempts // If request cannot be cloned (e.g., streaming body), we cannot retry let Some(req) = request.try_clone() else { log::warn!("[HttpClient] Request body cannot be cloned, retries not possible"); return self.client.execute(request).await; }; match self.client.execute(req).await { Ok(response) => return Ok(response), Err(error) => { last_error = Some(error); let err = last_error.as_ref().unwrap(); // Don't retry if it's not a retryable error if !Self::should_retry(err) { return Err(last_error.unwrap()); } // Don't retry on last attempt if attempt == max_retries { break; } // Wait before retrying (exponential backoff) let delay_ms = RETRY_DELAYS_MS .get(attempt as usize) .copied() .unwrap_or(*RETRY_DELAYS_MS.last().unwrap()); log::info!( "[HttpClient] Retry {}/{} after {}ms (error: {})", attempt + 1, max_retries, delay_ms, err ); tokio::time::sleep(Duration::from_millis(delay_ms)).await; } } } Err(last_error.unwrap()) } /// Make a GET request with retry pub async fn get_with_retry(&self, url: &str) -> Result { let request = self.client.get(url).build()?; self.request_with_retry(request).await } /// Make a GET request and deserialize JSON response with retry pub async fn get_json_with_retry( &self, url: &str, ) -> Result { let response = self.get_with_retry(url).await .map_err(|e| format!("Request failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(format!("HTTP {}: {}", status, error_text)); } response.json::().await .map_err(|e| format!("Failed to parse JSON: {}", e)) } /// Quick ping to check if a server is reachable (no retry) pub async fn ping(&self, url: &str) -> bool { let request = self.client.get(url) .timeout(Duration::from_secs(5)) // Shorter timeout for ping .build(); match request { Ok(req) => { match self.client.execute(req).await { Ok(response) => response.status().is_success(), Err(_) => false, } } Err(_) => false, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_auth_header_format() { let header = HttpClient::build_auth_header(Some("test_token"), "device456"); assert!(header.contains("MediaBrowser Client=\"JellyTau\"")); assert!(header.contains("Token=\"test_token\"")); assert!(header.contains("DeviceId=\"device456\"")); } #[test] fn test_auth_header_without_token() { let header = HttpClient::build_auth_header(None, "device456"); assert!(header.contains("MediaBrowser Client=\"JellyTau\"")); assert!(!header.contains("Token=")); assert!(header.contains("DeviceId=\"device456\"")); } #[test] fn test_retry_delays() { // Verify retry delays match TypeScript assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]); } }