use amp_rs::client::{RetryClient, RetryConfig, TokenError};
use httpmock::prelude::*;
use serial_test::serial;
use std::env;
use std::time::Duration as StdDuration;
#[test]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_attempts, 3);
assert_eq!(config.base_delay_ms, 1000);
assert_eq!(config.max_delay_ms, 30000);
assert_eq!(config.timeout_seconds, 10);
}
#[test]
fn test_retry_config_for_tests() {
let config = RetryConfig::for_tests();
assert_eq!(config.max_attempts, 2);
assert_eq!(config.base_delay_ms, 500);
assert_eq!(config.max_delay_ms, 5000);
assert_eq!(config.timeout_seconds, 5);
}
#[test]
#[serial]
fn test_retry_config_from_env_defaults() {
env::remove_var("API_RETRY_MAX_ATTEMPTS");
env::remove_var("API_RETRY_BASE_DELAY_MS");
env::remove_var("API_RETRY_MAX_DELAY_MS");
env::remove_var("API_REQUEST_TIMEOUT_SECONDS");
let config = RetryConfig::from_env().unwrap();
assert_eq!(config.max_attempts, 3);
assert_eq!(config.base_delay_ms, 1000);
assert_eq!(config.max_delay_ms, 30000);
assert_eq!(config.timeout_seconds, 10);
}
#[test]
#[serial]
fn test_retry_config_from_env_custom_values() {
dotenvy::dotenv().ok();
env::set_var("API_RETRY_MAX_ATTEMPTS", "5");
env::set_var("API_RETRY_BASE_DELAY_MS", "2000");
env::set_var("API_RETRY_MAX_DELAY_MS", "60000");
env::set_var("API_REQUEST_TIMEOUT_SECONDS", "30");
let config = RetryConfig::from_env().unwrap();
assert_eq!(config.max_attempts, 5);
assert_eq!(config.base_delay_ms, 2000);
assert_eq!(config.max_delay_ms, 60000);
assert_eq!(config.timeout_seconds, 30);
env::remove_var("API_RETRY_MAX_ATTEMPTS");
env::remove_var("API_RETRY_BASE_DELAY_MS");
env::remove_var("API_RETRY_MAX_DELAY_MS");
env::remove_var("API_REQUEST_TIMEOUT_SECONDS");
}
#[test]
#[serial]
fn test_retry_config_from_env_invalid_values() {
dotenvy::dotenv().ok();
env::set_var("API_RETRY_MAX_ATTEMPTS", "invalid");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid API_RETRY_MAX_ATTEMPTS"));
env::remove_var("API_RETRY_MAX_ATTEMPTS");
env::set_var("API_RETRY_BASE_DELAY_MS", "not_a_number");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid API_RETRY_BASE_DELAY_MS"));
env::remove_var("API_RETRY_BASE_DELAY_MS");
env::set_var("API_RETRY_MAX_DELAY_MS", "-1");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid API_RETRY_MAX_DELAY_MS"));
env::remove_var("API_RETRY_MAX_DELAY_MS");
env::set_var("API_REQUEST_TIMEOUT_SECONDS", "abc");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid API_REQUEST_TIMEOUT_SECONDS"));
env::remove_var("API_REQUEST_TIMEOUT_SECONDS");
}
#[test]
#[serial]
fn test_retry_config_validation() {
dotenvy::dotenv().ok();
env::set_var("API_RETRY_MAX_ATTEMPTS", "0");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("max_attempts must be greater than 0"));
env::remove_var("API_RETRY_MAX_ATTEMPTS");
env::set_var("API_RETRY_BASE_DELAY_MS", "0");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("base_delay_ms must be greater than 0"));
env::remove_var("API_RETRY_BASE_DELAY_MS");
env::set_var("API_RETRY_BASE_DELAY_MS", "2000");
env::set_var("API_RETRY_MAX_DELAY_MS", "1000");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("max_delay_ms must be greater than or equal to base_delay_ms"));
env::remove_var("API_RETRY_BASE_DELAY_MS");
env::remove_var("API_RETRY_MAX_DELAY_MS");
env::set_var("API_REQUEST_TIMEOUT_SECONDS", "0");
let result = RetryConfig::from_env();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("timeout_seconds must be greater than 0"));
env::remove_var("API_REQUEST_TIMEOUT_SECONDS");
}
#[test]
fn test_retry_config_builder_methods() {
let config = RetryConfig::default()
.with_timeout(20)
.with_max_attempts(5)
.with_base_delay_ms(2000)
.with_max_delay_ms(60000);
assert_eq!(config.timeout_seconds, 20);
assert_eq!(config.max_attempts, 5);
assert_eq!(config.base_delay_ms, 2000);
assert_eq!(config.max_delay_ms, 60000);
}
#[test]
#[serial]
fn test_retry_config_partial_env_vars() {
dotenvy::dotenv().ok();
env::set_var("API_RETRY_MAX_ATTEMPTS", "7");
env::set_var("API_RETRY_BASE_DELAY_MS", "1500");
let config = RetryConfig::from_env().unwrap();
assert_eq!(config.max_attempts, 7);
assert_eq!(config.base_delay_ms, 1500);
assert_eq!(config.max_delay_ms, 30000); assert_eq!(config.timeout_seconds, 10);
env::remove_var("API_RETRY_MAX_ATTEMPTS");
env::remove_var("API_RETRY_BASE_DELAY_MS");
}
#[test]
fn test_retry_client_creation() {
let config = RetryConfig::for_tests();
let retry_client = RetryClient::new(config.clone());
assert_eq!(retry_client.config().max_attempts, config.max_attempts);
assert_eq!(retry_client.config().base_delay_ms, config.base_delay_ms);
assert_eq!(retry_client.config().max_delay_ms, config.max_delay_ms);
assert_eq!(
retry_client.config().timeout_seconds,
config.timeout_seconds
);
}
#[test]
fn test_retry_client_with_default_config() {
let retry_client = RetryClient::with_default_config();
let default_config = RetryConfig::default();
assert_eq!(
retry_client.config().max_attempts,
default_config.max_attempts
);
assert_eq!(
retry_client.config().base_delay_ms,
default_config.base_delay_ms
);
assert_eq!(
retry_client.config().max_delay_ms,
default_config.max_delay_ms
);
assert_eq!(
retry_client.config().timeout_seconds,
default_config.timeout_seconds
);
}
#[test]
fn test_retry_client_for_tests() {
let retry_client = RetryClient::for_tests();
let test_config = RetryConfig::for_tests();
assert_eq!(retry_client.config().max_attempts, test_config.max_attempts);
assert_eq!(
retry_client.config().base_delay_ms,
test_config.base_delay_ms
);
assert_eq!(retry_client.config().max_delay_ms, test_config.max_delay_ms);
assert_eq!(
retry_client.config().timeout_seconds,
test_config.timeout_seconds
);
}
#[test]
fn test_retry_client_calculate_backoff_delay() {
let config = RetryConfig {
max_attempts: 3,
base_delay_ms: 1000,
max_delay_ms: 10000,
timeout_seconds: 10,
};
let retry_client = RetryClient::new(config);
let delay1 = retry_client.calculate_backoff_delay(1);
assert!(delay1.as_millis() >= 1000); assert!(delay1.as_millis() <= 1500);
let delay2 = retry_client.calculate_backoff_delay(2);
assert!(delay2.as_millis() >= 2000); assert!(delay2.as_millis() <= 2500);
let delay3 = retry_client.calculate_backoff_delay(3);
assert!(delay3.as_millis() >= 4000); assert!(delay3.as_millis() <= 4500);
let delay_large = retry_client.calculate_backoff_delay(10);
assert_eq!(delay_large.as_millis(), 10000); }
#[test]
fn test_retry_client_calculate_backoff_delay_with_small_max() {
let config = RetryConfig {
max_attempts: 5,
base_delay_ms: 1000,
max_delay_ms: 2000, timeout_seconds: 10,
};
let retry_client = RetryClient::new(config);
let delay3 = retry_client.calculate_backoff_delay(3);
assert_eq!(delay3.as_millis(), 2000);
let delay4 = retry_client.calculate_backoff_delay(4);
assert_eq!(delay4.as_millis(), 2000); }
#[test]
fn test_retry_client_extract_retry_after() {
use reqwest::header::{HeaderMap, HeaderValue};
let config = RetryConfig::for_tests();
let _retry_client = RetryClient::new(config);
let mut headers = HeaderMap::new();
headers.insert("retry-after", HeaderValue::from_static("120"));
let retry_after_value = headers
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
assert_eq!(retry_after_value, Some(120));
let mut headers_invalid = HeaderMap::new();
headers_invalid.insert("retry-after", HeaderValue::from_static("invalid"));
let retry_after_invalid = headers_invalid
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
assert_eq!(retry_after_invalid, None);
let headers_empty = HeaderMap::new();
let retry_after_missing = headers_empty
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
assert_eq!(retry_after_missing, None);
}
#[test]
fn test_retry_client_client_access() {
let retry_client = RetryClient::for_tests();
let client = retry_client.client();
assert!(client.get("https://example.com").build().is_ok());
}
#[test]
fn test_retry_client_config_access() {
let config = RetryConfig {
max_attempts: 5,
base_delay_ms: 2000,
max_delay_ms: 20000,
timeout_seconds: 15,
};
let retry_client = RetryClient::new(config.clone());
let retrieved_config = retry_client.config();
assert_eq!(retrieved_config.max_attempts, config.max_attempts);
assert_eq!(retrieved_config.base_delay_ms, config.base_delay_ms);
assert_eq!(retrieved_config.max_delay_ms, config.max_delay_ms);
assert_eq!(retrieved_config.timeout_seconds, config.timeout_seconds);
}
#[test]
fn test_retry_client_clone() {
let config = RetryConfig::for_tests();
let retry_client = RetryClient::new(config);
let cloned_client = retry_client.clone();
assert_eq!(
retry_client.config().max_attempts,
cloned_client.config().max_attempts
);
assert_eq!(
retry_client.config().base_delay_ms,
cloned_client.config().base_delay_ms
);
assert_eq!(
retry_client.config().max_delay_ms,
cloned_client.config().max_delay_ms
);
assert_eq!(
retry_client.config().timeout_seconds,
cloned_client.config().timeout_seconds
);
}
#[tokio::test]
async fn test_retry_client_with_mock_server() {
let server = MockServer::start();
let success_mock = server.mock(|when, then| {
when.method(GET).path("/success");
then.status(200).body("success");
});
let retry_client = RetryClient::for_tests();
let url = format!("{}/success", server.base_url());
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status(), 200);
success_mock.assert_hits(1); }
#[tokio::test]
async fn test_retry_client_server_error_retry() {
let server = MockServer::start();
let _server_error_mock = server.mock(|when, then| {
when.method(GET).path("/server-error");
then.status(500).body("server error");
});
let retry_client = RetryClient::for_tests();
let url = format!("{}/server-error", server.base_url());
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
assert!(result.is_err());
match result.unwrap_err() {
TokenError::ObtainFailed {
attempts,
last_error,
} => {
assert_eq!(attempts, 2); assert!(last_error.contains("Server error: 500"));
}
_ => panic!("Expected ObtainFailed error"),
}
_server_error_mock.assert_hits(2); }
#[tokio::test]
async fn test_retry_client_rate_limit_handling() {
let server = MockServer::start();
let rate_limit_mock = server.mock(|when, then| {
when.method(GET).path("/rate-limited");
then.status(429)
.header("retry-after", "1") .body("rate limited");
});
let retry_client = RetryClient::for_tests();
let url = format!("{}/rate-limited", server.base_url());
let start_time = std::time::Instant::now();
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
let elapsed = start_time.elapsed();
assert!(result.is_err());
match result.unwrap_err() {
TokenError::RateLimited {
retry_after_seconds,
} => {
assert_eq!(retry_after_seconds, 1);
}
_ => panic!("Expected RateLimited error"),
}
assert!(elapsed.as_secs() >= 1);
rate_limit_mock.assert_hits(2); }
#[tokio::test]
async fn test_retry_client_non_retryable_client_error() {
let server = MockServer::start();
let not_found_mock = server.mock(|when, then| {
when.method(GET).path("/not-found");
then.status(404).body("not found");
});
let retry_client = RetryClient::for_tests();
let url = format!("{}/not-found", server.base_url());
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
assert!(result.is_err());
match result.unwrap_err() {
TokenError::ObtainFailed {
attempts,
last_error,
} => {
assert_eq!(attempts, 1); assert!(last_error.contains("Client error: 404"));
}
_ => panic!("Expected ObtainFailed error"),
}
not_found_mock.assert_hits(1); }
#[tokio::test]
async fn test_retry_client_timeout_handling() {
let server = MockServer::start();
let _timeout_mock = server.mock(|when, then| {
when.method(GET).path("/timeout");
then.status(200)
.delay(StdDuration::from_secs(10)) .body("delayed response");
});
let config = RetryConfig {
max_attempts: 2,
base_delay_ms: 100,
max_delay_ms: 1000,
timeout_seconds: 1, };
let retry_client = RetryClient::new(config);
let url = format!("{}/timeout", server.base_url());
let start_time = std::time::Instant::now();
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
let elapsed = start_time.elapsed();
assert!(result.is_err());
let error = result.unwrap_err();
match error {
TokenError::Timeout { timeout_seconds } => {
assert_eq!(timeout_seconds, 1);
}
_ => panic!("Expected Timeout error, got: {:?}", error),
}
assert!(elapsed.as_secs() < 5);
}
#[tokio::test]
async fn test_retry_client_exhausted_retries() {
let server = MockServer::start();
let always_fail_mock = server.mock(|when, then| {
when.method(GET).path("/always-fail");
then.status(500).body("server error");
});
let retry_client = RetryClient::for_tests(); let url = format!("{}/always-fail", server.base_url());
let result = retry_client
.execute_with_retry(|| retry_client.client().get(&url))
.await;
assert!(result.is_err());
match result.unwrap_err() {
TokenError::ObtainFailed {
attempts,
last_error,
} => {
assert_eq!(attempts, 2); assert!(last_error.contains("Server error: 500"));
}
_ => panic!("Expected ObtainFailed error"),
}
always_fail_mock.assert_hits(2); }