264 lines
8.5 KiB
Rust
264 lines
8.5 KiB
Rust
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<Self, String> {
|
|
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<Response, reqwest::Error> {
|
|
let max_retries = self.config.max_retries;
|
|
let mut last_error: Option<reqwest::Error> = 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<Response, reqwest::Error> {
|
|
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<T: DeserializeOwned>(
|
|
&self,
|
|
url: &str,
|
|
) -> Result<T, String> {
|
|
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::<T>().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]);
|
|
}
|
|
}
|