use std::time::Duration;
use backon::{BackoffBuilder, ExponentialBuilder};
use reqwest::{Client, RequestBuilder, Response, StatusCode};
use tokio::time::timeout;
use tracing::{debug, instrument};
use crate::{
api::OdosApiErrorResponse,
api_key::ApiKey,
error::{ApiErrorBody, OdosError, Result},
error_code::OdosErrorCode,
};
#[derive(Debug, Clone, Copy, Default)]
pub enum RetryPredicate {
#[default]
Default,
Replace(fn(&OdosError) -> bool),
DefaultExcept(fn(&OdosError) -> bool),
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff_ms: u64,
pub retry_server_errors: bool,
pub retry_predicate: RetryPredicate,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff_ms: 100,
retry_server_errors: true,
retry_predicate: RetryPredicate::Default,
}
}
}
impl RetryConfig {
pub fn no_retries() -> Self {
Self {
max_retries: 0,
..Default::default()
}
}
pub fn conservative() -> Self {
Self {
max_retries: 2,
retry_server_errors: false,
..Default::default()
}
}
}
#[derive(Clone)]
pub struct ClientConfig {
pub timeout: Duration,
pub connect_timeout: Duration,
pub retry_config: RetryConfig,
pub max_connections: usize,
pub pool_idle_timeout: Duration,
pub api_key: Option<ApiKey>,
pub endpoint: crate::Endpoint,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
retry_config: RetryConfig::default(),
max_connections: 20,
pool_idle_timeout: Duration::from_secs(90),
api_key: None,
endpoint: crate::Endpoint::public_v2(),
}
}
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientConfig")
.field("timeout", &self.timeout)
.field("connect_timeout", &self.connect_timeout)
.field("retry_config", &self.retry_config)
.field("max_connections", &self.max_connections)
.field("pool_idle_timeout", &self.pool_idle_timeout)
.field("api_key", &self.api_key)
.field("endpoint", &self.endpoint)
.finish()
}
}
impl ClientConfig {
pub fn no_retries() -> Self {
Self {
retry_config: RetryConfig::no_retries(),
..Default::default()
}
}
pub fn conservative() -> Self {
Self {
retry_config: RetryConfig::conservative(),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct OdosHttpClient {
client: Client,
config: ClientConfig,
}
impl OdosHttpClient {
pub fn new() -> Result<Self> {
Self::with_config(ClientConfig::default())
}
pub fn with_config(config: ClientConfig) -> Result<Self> {
let client = Client::builder()
.timeout(config.timeout)
.connect_timeout(config.connect_timeout)
.pool_max_idle_per_host(config.max_connections)
.pool_idle_timeout(config.pool_idle_timeout)
.build()
.map_err(OdosError::Http)?;
Ok(Self { client, config })
}
#[instrument(skip(self, request_builder_fn), level = "debug")]
pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
where
F: Fn() -> RequestBuilder + Clone,
{
let initial_backoff_duration =
Duration::from_millis(self.config.retry_config.initial_backoff_ms);
let backoff = ExponentialBuilder::default()
.with_min_delay(initial_backoff_duration)
.with_max_delay(Duration::from_secs(30))
.with_max_times(self.config.retry_config.max_retries as usize + 1);
let mut backoff_iter = backoff.build();
let mut attempt = 0;
loop {
attempt += 1;
let request = match request_builder_fn().build() {
Ok(req) => req,
Err(e) => return Err(OdosError::Http(e)),
};
let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
{
Ok(Ok(response)) if response.status().is_success() => {
return Ok(response);
}
Ok(Ok(response)) => {
let status = response.status();
if status == StatusCode::TOO_MANY_REQUESTS {
let retry_after = extract_retry_after(&response);
let body = parse_error_response(response).await;
return Err(OdosError::RateLimit { retry_after, body });
} else {
let body = parse_error_response(response).await;
let error = OdosError::Api { status, body };
if !self.should_retry(&error, attempt) {
return Err(error);
}
error
}
}
Ok(Err(e)) => {
let is_timeout = e.is_timeout();
let is_connect = e.is_connect();
let error = OdosError::Http(e);
if !self.should_retry(&error, attempt) {
return Err(error);
}
debug!(
error_type = "http_error",
attempt,
error = %error,
is_timeout,
is_connect,
"HTTP error occurred, will retry with backoff"
);
error
}
Err(_) => {
let error = OdosError::timeout_error("Request timed out");
if !self.should_retry(&error, attempt) {
return Err(error);
}
debug!(
error_type = "timeout",
attempt,
timeout_secs = self.config.timeout.as_secs(),
"Request timed out, will retry with backoff"
);
error
}
};
if attempt >= self.config.retry_config.max_retries {
return Err(last_error);
}
if let Some(delay) = backoff_iter.next() {
tokio::time::sleep(delay).await;
} else {
return Err(last_error);
}
}
}
pub fn inner(&self) -> &Client {
&self.client
}
pub fn config(&self) -> &ClientConfig {
&self.config
}
fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
let retry_config = &self.config.retry_config;
if attempts >= retry_config.max_retries {
return false;
}
match retry_config.retry_predicate {
RetryPredicate::Replace(p) => return p(error),
RetryPredicate::DefaultExcept(veto) if veto(error) => return false,
RetryPredicate::Default | RetryPredicate::DefaultExcept(_) => {}
}
if !retry_config.retry_server_errors && error.is_server_error() {
return false;
}
error.is_retryable()
}
}
fn extract_retry_after(response: &Response) -> Option<Duration> {
response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
pub(crate) async fn parse_error_response(response: Response) -> ApiErrorBody {
let body_text = match response.text().await {
Ok(text) => text,
Err(e) => {
return ApiErrorBody {
message: format!("Failed to read response body: {e}"),
code: OdosErrorCode::Unknown(0),
trace_id: None,
};
}
};
match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
Ok(error_response) => ApiErrorBody {
message: error_response.detail,
code: OdosErrorCode::from(error_response.error_code),
trace_id: error_response.trace_id,
},
Err(_) => ApiErrorBody {
message: body_text,
code: OdosErrorCode::Unknown(0),
trace_id: None,
},
}
}
impl Default for OdosHttpClient {
fn default() -> Self {
Self::new().expect("Failed to create default HTTP client")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error_code::OdosErrorCode;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use wiremock::{
matchers::{method, path},
Mock, MockServer, Request, ResponseTemplate,
};
fn create_retry_mock(
first_status: u16,
first_body: String,
success_after: usize,
) -> impl Fn(&Request) -> ResponseTemplate {
let attempt_count = Arc::new(Mutex::new(0));
move |_req: &Request| {
let mut count = attempt_count.lock().unwrap();
*count += 1;
if *count < success_after {
ResponseTemplate::new(first_status).set_body_string(&first_body)
} else {
ResponseTemplate::new(200).set_body_string("Success")
}
}
}
fn create_test_client_with_predicate(
max_retries: u32,
timeout_ms: u64,
retry_predicate: RetryPredicate,
) -> OdosHttpClient {
let config = ClientConfig {
timeout: Duration::from_millis(timeout_ms),
retry_config: RetryConfig {
max_retries,
initial_backoff_ms: 10,
retry_predicate,
..Default::default()
},
..Default::default()
};
OdosHttpClient::with_config(config).unwrap()
}
fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
create_test_client_with_predicate(max_retries, timeout_ms, RetryPredicate::Default)
}
#[test]
fn test_client_config_default() {
let config = ClientConfig::default();
assert_eq!(config.timeout, Duration::from_secs(30));
assert_eq!(config.retry_config.max_retries, 3);
assert_eq!(config.max_connections, 20);
}
#[tokio::test]
async fn test_client_creation() {
let client = OdosHttpClient::new();
assert!(client.is_ok());
}
#[tokio::test]
async fn test_client_with_custom_config() {
let config = ClientConfig {
timeout: Duration::from_secs(60),
retry_config: RetryConfig {
max_retries: 5,
..Default::default()
},
..Default::default()
};
let client = OdosHttpClient::with_config(config.clone());
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.config().timeout, Duration::from_secs(60));
assert_eq!(client.config().retry_config.max_retries, 5);
}
#[tokio::test]
async fn test_rate_limit_with_retry_after() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(429)
.set_body_string("Rate limit exceeded")
.insert_header("retry-after", "1"),
)
.expect(1) .mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(
response.is_err(),
"Rate limit should return error immediately"
);
if let Err(OdosError::RateLimit {
retry_after, body, ..
}) = response
{
assert!(body.message.contains("Rate limit"));
assert_eq!(retry_after, Some(Duration::from_secs(1)));
} else {
panic!("Expected RateLimit error, got: {response:?}");
}
}
#[tokio::test]
async fn test_rate_limit_without_retry_after() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
.expect(1) .mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(
response.is_err(),
"Rate limit should return error immediately"
);
if let Err(OdosError::RateLimit {
retry_after, body, ..
}) = response
{
assert!(body.message.contains("Rate limit"));
assert_eq!(retry_after, None);
} else {
panic!("Expected RateLimit error, got: {response:?}");
}
}
#[tokio::test]
async fn test_non_retryable_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
.expect(1)
.mount(&mock_server)
.await;
let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(!e.is_retryable());
}
}
#[tokio::test]
async fn test_retry_exhaustion_returns_last_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
.mount(&mock_server)
.await;
let client = create_test_client(2, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(
matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
);
}
}
#[tokio::test]
async fn test_timeout_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("Success")
.set_delay(Duration::from_secs(5)),
)
.mount(&mock_server)
.await;
let client = create_test_client(2, 100);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
let is_timeout = matches!(e, OdosError::Timeout(_))
|| matches!(e, OdosError::Http(ref err) if err.is_timeout());
assert!(is_timeout, "Expected timeout error, got: {e:?}");
}
}
#[tokio::test]
async fn test_invalid_request_builder_fails_immediately() {
let client = OdosHttpClient::default();
let bad_builder = || {
let mut builder = client.inner().get("http://localhost");
builder = builder.header("x".repeat(100000), "value");
builder
};
let result = client.execute_with_retry(bad_builder).await;
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, OdosError::Http(_)));
}
}
#[tokio::test]
async fn test_retryable_500_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(
500,
"Internal server error".to_string(),
2,
))
.mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_ok(), "500 error should be retried and succeed");
}
#[tokio::test]
async fn test_retryable_502_bad_gateway() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
.mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_ok(), "502 error should be retried and succeed");
}
#[tokio::test]
async fn test_retryable_503_service_unavailable() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
.mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_ok(), "503 error should be retried and succeed");
}
#[tokio::test]
async fn test_retryable_504_gateway_timeout() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
.mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_ok(), "504 error should be retried and succeed");
}
#[tokio::test]
async fn test_algo_internal_2999_not_retried() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Error getting quote, please try again",
"traceId": "10becdc8-a021-4491-8201-a17b657204e0",
"errorCode": 2999
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string(error_json))
.expect(1)
.mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
match response {
Err(OdosError::Api { status, body }) => {
assert_eq!(body.code, OdosErrorCode::AlgoInternal);
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
other => panic!("Expected OdosError::Api with AlgoInternal, got: {other:?}"),
}
}
#[tokio::test]
async fn test_typed_retryable_code_still_retried() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Algorithm timeout",
"traceId": "20becdc8-a021-4491-8201-a17b657204e0",
"errorCode": 2998
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string(error_json))
.expect(3) .mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
match response {
Err(OdosError::Api { body, .. }) => {
assert_eq!(body.code, OdosErrorCode::AlgoTimeout);
}
other => panic!("Expected OdosError::Api with AlgoTimeout, got: {other:?}"),
}
}
#[tokio::test]
async fn test_retry_predicate_default_matches_built_in_tree() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
.mount(&mock_server)
.await;
let client = create_test_client_with_predicate(3, 30000, RetryPredicate::Default);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(
response.is_ok(),
"502 should still be retried under RetryPredicate::Default"
);
}
#[tokio::test]
async fn test_retry_predicate_replace_can_disable_retries() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
.expect(1) .mount(&mock_server)
.await;
let client =
create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| false));
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
}
#[tokio::test]
async fn test_retry_predicate_applies_to_timeouts() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("Success")
.set_delay(Duration::from_secs(5)),
)
.expect(1) .mount(&mock_server)
.await;
let client =
create_test_client_with_predicate(3, 100, RetryPredicate::Replace(|_err| false));
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
}
#[tokio::test]
async fn test_retry_predicate_replace_can_force_retries() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
.expect(3) .mount(&mock_server)
.await;
let client =
create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| true));
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
}
#[tokio::test]
async fn test_retry_predicate_replace_bypasses_retry_server_errors_gate() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
.expect(3)
.mount(&mock_server)
.await;
let config = ClientConfig {
timeout: Duration::from_millis(30000),
retry_config: RetryConfig {
max_retries: 3,
initial_backoff_ms: 10,
retry_server_errors: false,
retry_predicate: RetryPredicate::Replace(|_err| true),
},
..Default::default()
};
let client = OdosHttpClient::with_config(config).unwrap();
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
match response {
Err(OdosError::Api { status, .. }) => {
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
other => panic!("Expected OdosError::Api with 500 status, got: {other:?}"),
}
}
#[tokio::test]
async fn test_retry_predicate_default_except_vetoes_retryable_code() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Pricing service internal error",
"traceId": "30becdc8-a021-4491-8201-a17b657204e0",
"errorCode": 3130
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string(error_json))
.expect(1) .mount(&mock_server)
.await;
let client = create_test_client_with_predicate(
3,
30000,
RetryPredicate::DefaultExcept(|err| {
err.error_code() == Some(&OdosErrorCode::PricingInternal)
}),
);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
match response {
Err(OdosError::Api { body, .. }) => {
assert_eq!(body.code, OdosErrorCode::PricingInternal);
}
other => panic!("Expected OdosError::Api with PricingInternal, got: {other:?}"),
}
}
#[tokio::test]
async fn test_retry_predicate_default_except_falls_through_when_not_matched() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
.mount(&mock_server)
.await;
let client = create_test_client_with_predicate(
3,
30000,
RetryPredicate::DefaultExcept(|_err| false),
);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(
response.is_ok(),
"502 should still be retried when DefaultExcept veto returns false"
);
}
#[tokio::test]
async fn test_network_error_retryable() {
let client = create_test_client(2, 100);
let response = client
.execute_with_retry(|| client.inner().get("http://localhost:1"))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(matches!(e, OdosError::Http(_)));
}
}
#[test]
fn test_accessor_methods() {
let config = ClientConfig {
timeout: Duration::from_secs(45),
retry_config: RetryConfig {
max_retries: 5,
..Default::default()
},
..Default::default()
};
let client = OdosHttpClient::with_config(config.clone()).unwrap();
assert_eq!(client.config().timeout, Duration::from_secs(45));
assert_eq!(client.config().retry_config.max_retries, 5);
let _inner: &reqwest::Client = client.inner();
}
#[test]
fn test_default_client() {
let client = OdosHttpClient::default();
assert_eq!(client.config().timeout, Duration::from_secs(30));
assert_eq!(client.config().retry_config.max_retries, 3);
}
#[test]
fn test_extract_retry_after_valid_numeric() {
let response = reqwest::Response::from(
http::Response::builder()
.status(429)
.header("retry-after", "30")
.body("")
.unwrap(),
);
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, Some(Duration::from_secs(30)));
}
#[test]
fn test_extract_retry_after_missing_header() {
let response =
reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, None);
}
#[test]
fn test_extract_retry_after_malformed_value() {
let response = reqwest::Response::from(
http::Response::builder()
.status(429)
.header("retry-after", "not-a-number")
.body("")
.unwrap(),
);
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, None);
}
#[test]
fn test_extract_retry_after_zero_value() {
let response = reqwest::Response::from(
http::Response::builder()
.status(429)
.header("retry-after", "0")
.body("")
.unwrap(),
);
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, Some(Duration::from_secs(0)));
}
#[tokio::test]
async fn test_rate_limit_with_retry_after_zero() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(429)
.set_body_string("Rate limit exceeded")
.insert_header("retry-after", "0"),
)
.expect(1) .mount(&mock_server)
.await;
let client = create_test_client(3, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(
response.is_err(),
"Rate limit should return error immediately"
);
if let Err(OdosError::RateLimit {
retry_after, body, ..
}) = response
{
assert!(body.message.contains("Rate limit"));
assert_eq!(retry_after, Some(Duration::from_secs(0)));
} else {
panic!("Expected RateLimit error, got: {response:?}");
}
}
#[test]
fn test_extract_retry_after_large_value() {
let response = reqwest::Response::from(
http::Response::builder()
.status(429)
.header("retry-after", "3600")
.body("")
.unwrap(),
);
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, Some(Duration::from_secs(3600)));
}
#[test]
fn test_extract_retry_after_invalid_utf8() {
let response = reqwest::Response::from(
http::Response::builder()
.status(429)
.header("retry-after", vec![0xff, 0xfe])
.body("")
.unwrap(),
);
let retry_after = extract_retry_after(&response);
assert_eq!(retry_after, None);
}
#[test]
fn test_client_config_debug_redacts_api_key() {
use crate::ApiKey;
use uuid::Uuid;
let uuid = Uuid::new_v4();
let uuid_str = uuid.to_string();
let api_key = ApiKey::new(uuid);
let config = ClientConfig {
api_key: Some(api_key),
..Default::default()
};
let debug_output = format!("{:?}", config);
assert!(debug_output.contains("[REDACTED]"));
assert!(
!debug_output.contains(&uuid_str),
"API key UUID should not appear in debug output, but found: {}",
uuid_str
);
}
#[tokio::test]
async fn test_max_retries_zero() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
.expect(1) .mount(&mock_server)
.await;
let client = create_test_client(0, 30000); let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(
matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
);
}
}
#[tokio::test]
async fn test_parse_structured_error_response() {
let error_json = r#"{
"detail": "Error getting quote, please try again",
"traceId": "10becdc8-a021-4491-8201-a17b657204e0",
"errorCode": 2999
}"#;
let http_response = http::Response::builder()
.status(500)
.body(error_json)
.unwrap();
let response = reqwest::Response::from(http_response);
let parsed = parse_error_response(response).await;
assert_eq!(parsed.message, "Error getting quote, please try again");
assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
assert!(parsed.trace_id.is_some());
assert_eq!(
parsed.trace_id.unwrap().to_string(),
"10becdc8-a021-4491-8201-a17b657204e0"
);
}
#[tokio::test]
async fn test_parse_unstructured_error_response() {
let http_response = http::Response::builder()
.status(500)
.body("Internal server error")
.unwrap();
let response = reqwest::Response::from(http_response);
let parsed = parse_error_response(response).await;
assert_eq!(parsed.message, "Internal server error");
assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
assert!(parsed.trace_id.is_none());
}
#[tokio::test]
async fn test_api_error_with_structured_response() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Invalid chain ID",
"traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
"errorCode": 4001
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(400).set_body_string(error_json))
.expect(1)
.mount(&mock_server)
.await;
let client = create_test_client(0, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(matches!(e, OdosError::Api { .. }));
let error_code = e.error_code();
assert!(error_code.is_some());
assert!(error_code.unwrap().is_invalid_chain_id());
let trace_id = e.trace_id();
assert!(trace_id.is_some());
} else {
panic!("Expected error, got success");
}
}
#[tokio::test]
async fn test_api_error_with_null_trace_id_preserves_error_code() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Error getting quote, please try again",
"traceId": null,
"errorCode": 2999
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(ResponseTemplate::new(500).set_body_string(error_json))
.expect(1)
.mount(&mock_server)
.await;
let client = create_test_client(0, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
let err = response.expect_err("expected API error");
assert!(matches!(err, OdosError::Api { .. }));
assert_eq!(err.error_code(), Some(&OdosErrorCode::AlgoInternal));
assert_eq!(err.trace_id(), None);
}
#[tokio::test]
async fn test_client_config_failure() {
let config = ClientConfig {
max_connections: usize::MAX,
..Default::default()
};
let result = OdosHttpClient::with_config(config);
match result {
Ok(_) => {
}
Err(e) => {
assert!(matches!(e, OdosError::Http(_)));
}
}
}
#[tokio::test]
async fn test_rate_limit_with_trace_id() {
let mock_server = MockServer::start().await;
let error_json = r#"{
"detail": "Rate limit exceeded",
"traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"errorCode": 4299
}"#;
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(429)
.set_body_string(error_json)
.insert_header("retry-after", "30"),
)
.expect(1)
.mount(&mock_server)
.await;
let client = create_test_client(0, 30000);
let response = client
.execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
.await;
assert!(response.is_err());
if let Err(e) = response {
assert!(e.is_rate_limit());
let trace_id = e.trace_id();
assert!(trace_id.is_some());
assert_eq!(
trace_id.unwrap().to_string(),
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
);
let error_msg = e.to_string();
assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
assert!(error_msg.contains("[trace:"));
} else {
panic!("Expected error, got success");
}
}
}