use crate::application::auth::{Auth, Session, WebsocketInfo};
use crate::application::config::{Config, RateLimiterConfig};
use crate::application::rate_limiter::{RateLimitClass, RateLimiter};
use crate::constants::USER_AGENT;
use crate::error::AppError;
use crate::model::retry::RetryConfig;
use reqwest::Client as HttpInternalClient;
use reqwest::{Client, Method, Response, StatusCode};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tracing::{debug, error, warn};
pub struct HttpClient {
auth: Arc<Auth>,
http_client: HttpInternalClient,
config: Arc<Config>,
pool: Vec<KeySlot>,
selected: Arc<StdMutex<SelectedAccount>>,
primary: AtomicUsize,
cursor: AtomicUsize,
}
struct SelectedAccount {
id: String,
limiter: RateLimiter,
}
struct KeySlot {
api_key: String,
auth: Arc<Auth>,
rate_limiter: RateLimiter,
cooldown_until: Arc<StdMutex<Option<Instant>>>,
}
const KEY_COOLDOWN: Duration = Duration::from_secs(60);
#[derive(Clone, Copy)]
pub struct Pacing<'a> {
pub key: &'a RateLimiter,
pub account: Option<&'a RateLimiter>,
}
impl<'a> Pacing<'a> {
#[must_use]
pub const fn key_only(key: &'a RateLimiter) -> Self {
Self { key, account: None }
}
async fn charge_account(&self, class: RateLimitClass) {
if class == RateLimitClass::Trading {
return;
}
if let Some(account) = self.account {
account.wait_for(RateLimitClass::NonTrading).await;
}
}
}
const ACCOUNT_MAX_REQUESTS_PER_MINUTE: u32 = 30;
const ACCOUNT_PERIOD_SECONDS: u64 = 60;
fn redact_key(api_key: &str) -> String {
let head: String = api_key.chars().take(8).collect();
format!("{head}…")
}
impl KeySlot {
fn in_cooldown(&self) -> bool {
let guard = match self.cooldown_until.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.is_some_and(|until| Instant::now() < until)
}
fn cooldown_deadline(&self) -> Option<Instant> {
let guard = match self.cooldown_until.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.filter(|&until| Instant::now() < until)
}
fn mark_exhausted(&self) {
let until = Instant::now() + KEY_COOLDOWN;
match self.cooldown_until.lock() {
Ok(mut g) => *g = Some(until),
Err(poisoned) => *poisoned.into_inner() = Some(until),
}
}
}
impl HttpClient {
pub async fn new(config: Config) -> Result<Self, AppError> {
let config = Arc::new(config);
let http_client = HttpInternalClient::builder()
.user_agent(USER_AGENT)
.build()?;
let account_limiter = Self::build_account_limiter(&config.credentials.account_id);
let selected = Arc::new(StdMutex::new(SelectedAccount {
id: config.credentials.account_id.clone(),
limiter: account_limiter.clone(),
}));
let (auth, pool) = Self::build_pool(&config, &account_limiter)?;
let primary = Self::login_any(&pool).await?;
let auth = pool
.get(primary)
.map_or_else(|| auth.clone(), |slot| slot.auth.clone());
Ok(Self {
auth,
http_client,
config,
pool,
selected,
primary: AtomicUsize::new(primary),
cursor: AtomicUsize::new(0),
})
}
pub fn new_lazy(config: Config) -> Result<Self, AppError> {
let config = Arc::new(config);
let http_client = HttpInternalClient::builder()
.user_agent(USER_AGENT)
.build()?;
let account_limiter = Self::build_account_limiter(&config.credentials.account_id);
let selected = Arc::new(StdMutex::new(SelectedAccount {
id: config.credentials.account_id.clone(),
limiter: account_limiter.clone(),
}));
let (auth, pool) = Self::build_pool(&config, &account_limiter)?;
Ok(Self {
auth,
http_client,
config,
pool,
selected,
primary: AtomicUsize::new(0),
cursor: AtomicUsize::new(0),
})
}
fn build_pool(
config: &Arc<Config>,
account_limiter: &RateLimiter,
) -> Result<(Arc<Auth>, Vec<KeySlot>), AppError> {
let keys = config.credentials.api_keys();
let keys = if keys.is_empty() {
vec![config.credentials.api_key.clone()]
} else {
keys
};
let mut first_auth: Option<Arc<Auth>> = None;
let mut pool = Vec::with_capacity(keys.len());
for key in &keys {
let mut key_config = (**config).clone();
key_config.credentials.api_key = key.clone();
let key_config = Arc::new(key_config);
let key_limiter = RateLimiter::new(&key_config.rate_limiter);
let key_auth = Arc::new(Auth::with_limiters(
key_config,
key_limiter.clone(),
account_limiter.clone(),
)?);
if first_auth.is_none() {
first_auth = Some(key_auth.clone());
}
pool.push(KeySlot {
api_key: key.clone(),
rate_limiter: key_limiter,
auth: key_auth,
cooldown_until: Arc::new(StdMutex::new(None)),
});
}
if pool.len() > 1 {
debug!(keys = pool.len(), "API key pool enabled");
}
let auth = match first_auth {
Some(auth) => auth,
None => Arc::new(Auth::with_limiters(
config.clone(),
RateLimiter::new(&config.rate_limiter),
account_limiter.clone(),
)?),
};
Ok((auth, pool))
}
fn build_account_limiter(account_id: &str) -> RateLimiter {
static ACCOUNT_LIMITERS: OnceLock<StdMutex<HashMap<String, RateLimiter>>> = OnceLock::new();
let registry = ACCOUNT_LIMITERS.get_or_init(|| StdMutex::new(HashMap::new()));
let mut guard = match registry.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard
.entry(account_id.to_string())
.or_insert_with(|| {
RateLimiter::new(&RateLimiterConfig {
max_requests: ACCOUNT_MAX_REQUESTS_PER_MINUTE,
period_seconds: ACCOUNT_PERIOD_SECONDS,
burst_size: 1,
})
})
.clone()
}
async fn login_any(pool: &[KeySlot]) -> Result<usize, AppError> {
let mut last: Option<AppError> = None;
for (index, slot) in pool.iter().enumerate() {
match slot.auth.login().await {
Ok(_) => return Ok(index),
Err(e @ AppError::ApiKeyAllowanceExceeded) => {
slot.mark_exhausted();
warn!(
key = %redact_key(&slot.api_key),
"API key allowance exhausted at login, trying the next key"
);
last = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last.unwrap_or(AppError::ApiKeyAllowanceExceeded))
}
async fn reserve_slot(
&self,
class: RateLimitClass,
tried: &[usize],
) -> Result<Option<usize>, AppError> {
if class == RateLimitClass::Trading {
let primary = self.primary_index();
if tried.contains(&primary) {
return Ok(None);
}
self.pool[primary].rate_limiter.reserve(class).await;
self.pool[primary].auth.get_session().await?;
return Ok(Some(primary));
}
let len = self.pool.len();
let start = self.cursor.fetch_add(1, Ordering::Relaxed);
let candidates: Vec<usize> = (0..len)
.map(|offset| start.wrapping_add(offset) % len)
.filter(|i| !tried.contains(i))
.collect();
if candidates.is_empty() {
return Ok(None);
}
for &i in &candidates {
let slot = &self.pool[i];
if slot.in_cooldown() || !slot.auth.has_ready_session().await {
continue;
}
if slot.rate_limiter.try_reserve(class) {
self.promote_primary_if_needed(i);
return Ok(Some(i));
}
}
for &i in &candidates {
let slot = &self.pool[i];
if slot.in_cooldown() || slot.auth.has_ready_session().await {
continue;
}
match slot.auth.get_session().await {
Ok(_) => {}
Err(AppError::ApiKeyAllowanceExceeded) if self.pool.len() > 1 => {
slot.mark_exhausted();
warn!(
key = %redact_key(&slot.api_key),
"API key allowance exhausted during login, trying another key"
);
continue;
}
Err(e) => return Err(e),
}
self.promote_primary_if_needed(i);
if slot.rate_limiter.try_reserve(class) {
return Ok(Some(i));
}
break;
}
let live: Vec<usize> = candidates
.iter()
.copied()
.filter(|&i| !self.pool[i].in_cooldown())
.collect();
let waiting = if live.is_empty() {
if let Some(until) = candidates
.iter()
.filter_map(|&i| self.pool[i].cooldown_deadline())
.min()
{
let now = Instant::now();
if until > now {
debug!(
wait_ms = (until - now).as_millis(),
"every key is cooling down"
);
tokio::time::sleep(until - now).await;
}
}
candidates
} else {
live
};
let mut ready = Vec::with_capacity(waiting.len());
for &i in &waiting {
if self.pool[i].auth.has_ready_session().await {
ready.push(i);
}
}
let waiting = if ready.is_empty() { waiting } else { ready };
let waits: Vec<_> = waiting
.iter()
.map(|&i| {
let slot = &self.pool[i];
Box::pin(async move {
slot.rate_limiter.reserve(class).await;
i
})
})
.collect();
let (winner, _, _) = futures::future::select_all(waits).await;
self.pool[winner].auth.get_session().await?;
self.promote_primary_if_needed(winner);
Ok(Some(winner))
}
pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
self.primary_auth().ws_info().await
}
#[deprecated(
note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
)]
pub async fn get_ws_info(&self) -> WebsocketInfo {
self.ws_info().await.unwrap_or_default()
}
pub async fn get<T: DeserializeOwned>(
&self,
path: &str,
version: Option<u8>,
) -> Result<T, AppError> {
self.request(Method::GET, path, None::<()>, version).await
}
pub async fn post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: B,
version: Option<u8>,
) -> Result<T, AppError> {
self.request(Method::POST, path, Some(body), version).await
}
pub async fn put<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: B,
version: Option<u8>,
) -> Result<T, AppError> {
self.request(Method::PUT, path, Some(body), version).await
}
pub async fn delete<T: DeserializeOwned>(
&self,
path: &str,
version: Option<u8>,
) -> Result<T, AppError> {
self.request(Method::DELETE, path, None::<()>, version)
.await
}
pub async fn post_with_delete_method<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: B,
version: Option<u8>,
) -> Result<T, AppError> {
self.request_with_refresh(
Method::POST,
path,
Some(body),
version,
&[("_method", "DELETE")],
)
.await
}
pub async fn request<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<B>,
version: Option<u8>,
) -> Result<T, AppError> {
self.request_with_refresh(method, path, body, version, &[])
.await
}
async fn request_with_refresh<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<B>,
version: Option<u8>,
extra_headers: &[(&str, &str)],
) -> Result<T, AppError> {
let response = self
.request_internal(method, path, &body, version, extra_headers)
.await?;
self.parse_response(response).await
}
async fn request_internal<B: Serialize>(
&self,
method: Method,
path: &str,
body: &Option<B>,
version: Option<u8>,
extra_headers: &[(&str, &str)],
) -> Result<Response, AppError> {
let url = if path.starts_with("http") {
path.to_string()
} else {
let path = path.trim_start_matches('/');
format!("{}/{}", self.config.rest_api.base_url, path)
};
let class = classify_endpoint(&method, path);
let may_rotate = class == RateLimitClass::NonTrading && self.pool.len() > 1;
let mut tried: Vec<usize> = Vec::with_capacity(self.pool.len());
let mut replayed = false;
let mut replay_on: Option<usize> = None;
loop {
let idx = if let Some(pinned) = replay_on.take() {
self.pool[pinned].rate_limiter.reserve(class).await;
pinned
} else if let Some(selected) = self.reserve_slot(class, &tried).await? {
selected
} else {
return Err(AppError::ApiKeyAllowanceExceeded);
};
if !tried.contains(&idx) {
tried.push(idx);
}
let slot = &self.pool[idx];
let rotate = may_rotate && tried.len() < self.pool.len();
let retry = if rotate {
RetryConfig {
max_retry_count: Some(0),
retry_delay_secs: None,
}
} else {
RetryConfig::default()
};
let result = self
.request_on_slot(slot, &method, &url, body, version, extra_headers, retry)
.await;
match result {
Err(AppError::ApiKeyAllowanceExceeded) => {
slot.mark_exhausted();
if !rotate {
return Err(AppError::ApiKeyAllowanceExceeded);
}
warn!(
key = %redact_key(&slot.api_key),
of = self.pool.len(),
"API key allowance exhausted, rotating to another key"
);
}
Err(AppError::OAuthTokenExpired | AppError::Unauthorized) if !replayed => {
warn!(
key = %redact_key(&slot.api_key),
"session rejected by IG, re-authenticating this key and replaying once"
);
slot.auth.force_refresh().await?;
replayed = true;
replay_on = Some(idx);
}
other => return other,
}
}
}
#[allow(clippy::too_many_arguments)]
async fn request_on_slot<B: Serialize>(
&self,
slot: &KeySlot,
method: &Method,
url: &str,
body: &Option<B>,
version: Option<u8>,
extra_headers: &[(&str, &str)],
retry: RetryConfig,
) -> Result<Response, AppError> {
let session = slot.auth.get_session().await?;
let (selected_account, account_limiter) = self.selected_account();
let version_owned = version.unwrap_or(1).to_string();
let auth_header_value;
let mut headers = vec![
("X-IG-API-KEY", slot.api_key.as_str()),
("Content-Type", "application/json; charset=UTF-8"),
("Accept", "application/json; charset=UTF-8"),
("Version", version_owned.as_str()),
];
headers.extend_from_slice(extra_headers);
if let Some(oauth) = &session.oauth_token {
auth_header_value = format!("Bearer {}", oauth.access_token);
headers.push(("Authorization", auth_header_value.as_str()));
headers.push(("IG-ACCOUNT-ID", selected_account.as_str()));
} else if let (Some(cst_val), Some(token_val)) = (&session.cst, &session.x_security_token) {
headers.push(("CST", cst_val.as_str()));
headers.push(("X-SECURITY-TOKEN", token_val.as_str()));
}
make_http_request_paced(
&self.http_client,
Pacing {
key: &slot.rate_limiter,
account: Some(&account_limiter),
},
method.clone(),
url,
headers,
body,
retry,
true,
)
.await
}
async fn parse_response<T: DeserializeOwned>(&self, response: Response) -> Result<T, AppError> {
let status = response.status();
let url = response.url().clone();
let text = response.text().await?;
serde_json::from_str(&text).map_err(|e| {
if is_auth_endpoint(url.path()) {
AppError::Deserialization(format!("failed to deserialize {url} ({status}): {e}"))
} else {
let snippet = truncate_body_snippet(&text);
AppError::Deserialization(format!(
"failed to deserialize {url} ({status}): {e}; body: {snippet}"
))
}
})
}
pub async fn switch_account(
&self,
account_id: &str,
default_account: Option<bool>,
) -> Result<(), AppError> {
if default_account == Some(true) {
return Err(AppError::InvalidInput(
"default_account=true is not supported: making an account the \
login default is not performed by this client; pass None or \
Some(false) to switch for this client only"
.to_string(),
));
}
let is_oauth = self.config.api_version.unwrap_or(2) == 3;
if !is_oauth {
if self.pool.len() > 1 {
return Err(AppError::InvalidInput(
"switch_account cannot be applied coherently to a multi-key v2 \
pool: each key holds the account in its own session, so this \
would cost one request per key; use api_version 3, which \
selects the account per request, or build one client per \
account"
.to_string(),
));
}
self.primary_auth()
.switch_account(account_id, default_account)
.await?;
}
let limiter = Self::build_account_limiter(account_id);
{
let mut guard = match self.selected.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.id = account_id.to_string();
guard.limiter = limiter;
}
debug!(account = %account_id, oauth = is_oauth, "account switched");
Ok(())
}
pub async fn get_session(&self) -> Result<Session, AppError> {
self.primary_auth().get_session().await
}
pub async fn logout(&self) -> Result<(), AppError> {
self.primary_auth().logout().await
}
pub fn auth(&self) -> &Auth {
self.primary_auth()
}
fn selected_account(&self) -> (String, RateLimiter) {
let guard = match self.selected.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
(guard.id.clone(), guard.limiter.clone())
}
fn primary_auth(&self) -> &Arc<Auth> {
let index = self.primary_index();
self.pool.get(index).map_or(&self.auth, |slot| &slot.auth)
}
fn primary_index(&self) -> usize {
self.primary
.load(Ordering::Relaxed)
.min(self.pool.len().saturating_sub(1))
}
fn promote_primary_if_needed(&self, index: usize) {
let current = self.primary_index();
if current == index {
return;
}
let incumbent_usable = self
.pool
.get(current)
.is_some_and(|slot| !slot.in_cooldown());
if !incumbent_usable {
self.primary.store(index, Ordering::Relaxed);
if let Some(slot) = self.pool.get(index) {
debug!(
key = %redact_key(&slot.api_key),
"primary key moved to a key that can authenticate"
);
}
}
}
#[inline]
#[must_use]
pub fn config(&self) -> &Config {
&self.config
}
}
pub async fn make_http_request<B: Serialize>(
client: &Client,
rate_limiter: &RateLimiter,
method: Method,
url: &str,
headers: Vec<(&str, &str)>,
body: &Option<B>,
retry_config: RetryConfig,
) -> Result<Response, AppError> {
make_http_request_paced(
client,
Pacing::key_only(rate_limiter),
method,
url,
headers,
body,
retry_config,
false,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn make_http_request_with_account<B: Serialize>(
client: &Client,
pacing: Pacing<'_>,
method: Method,
url: &str,
headers: Vec<(&str, &str)>,
body: &Option<B>,
retry_config: RetryConfig,
) -> Result<Response, AppError> {
make_http_request_paced(
client,
pacing,
method,
url,
headers,
body,
retry_config,
false,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn make_http_request_reserved<B: Serialize>(
client: &Client,
rate_limiter: &RateLimiter,
method: Method,
url: &str,
headers: Vec<(&str, &str)>,
body: &Option<B>,
retry_config: RetryConfig,
reserved: bool,
) -> Result<Response, AppError> {
make_http_request_paced(
client,
Pacing::key_only(rate_limiter),
method,
url,
headers,
body,
retry_config,
reserved,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn make_http_request_paced<B: Serialize>(
client: &Client,
pacing: Pacing<'_>,
method: Method,
url: &str,
headers: Vec<(&str, &str)>,
body: &Option<B>,
retry_config: RetryConfig,
reserved: bool,
) -> Result<Response, AppError> {
let rate_limiter = pacing.key;
let max_retries = retry_config.max_retries();
let class = classify_endpoint(&method, url);
for attempt in 0..=max_retries {
if attempt > 0 || !reserved {
rate_limiter.wait_for(class).await;
}
pacing.charge_account(class).await;
debug!(%method, %url, class = ?class, "http request");
let mut request = client.request(method.clone(), url);
for (name, value) in &headers {
request = request.header(*name, *value);
}
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
debug!(status = ?status, "http response");
if status.is_success() {
return Ok(response);
}
let retryable_err: AppError = match status {
StatusCode::FORBIDDEN => {
let body_text = response.text().await.unwrap_or_default();
if body_text.contains("exceeded-account-historical-data-allowance") {
error!("historical data allowance exceeded (weekly quota exhausted)");
return Err(AppError::HistoricalDataAllowanceExceeded {
allowance_expiry: 0,
});
}
if body_text.contains("exceeded-api-key-allowance") {
warn!("api key allowance exceeded");
return Err(AppError::ApiKeyAllowanceExceeded);
} else if body_text.contains("exceeded-account-trading-allowance") {
warn!("account trading allowance exceeded");
return Err(AppError::TradingAllowanceExceeded);
} else if body_text.contains("exceeded-account-allowance") {
warn!("account allowance exceeded");
return Err(AppError::AccountAllowanceExceeded);
} else {
error!(status = ?status, "forbidden");
return Err(AppError::Unexpected(status));
}
}
StatusCode::UNAUTHORIZED => {
let body_text = response.text().await.unwrap_or_default();
if body_text.contains("oauth-token-invalid") {
return Err(AppError::OAuthTokenExpired);
}
error!(status = ?status, "unauthorized");
return Err(AppError::Unauthorized);
}
other => match classify_status(other) {
StatusClass::Retryable => {
let _ = response.bytes().await;
if other == StatusCode::TOO_MANY_REQUESTS {
warn!(status = ?other, "rate limit (429) hit");
AppError::RateLimitExceeded
} else {
warn!(status = ?other, "server error");
AppError::Unexpected(other)
}
}
StatusClass::Permanent => {
error!(status = ?other, "request failed");
return Err(AppError::Unexpected(other));
}
},
};
if attempt < max_retries {
let delay = retry_config.delay_for_attempt(attempt);
let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
warn!(
attempt = attempt.saturating_add(1),
max_retries, delay_ms, "retrying after transient failure"
);
tokio::time::sleep(delay).await;
continue;
}
error!(max_retries, "retries exhausted after transient failures");
return Err(retryable_err);
}
Err(AppError::RateLimitExceeded)
}
const BODY_SNIPPET_MAX_CHARS: usize = 500;
#[must_use]
#[inline]
fn is_auth_endpoint(path: &str) -> bool {
path.contains("/session")
}
#[must_use]
#[inline]
fn truncate_body_snippet(body: &str) -> String {
let truncated = match body.char_indices().nth(BODY_SNIPPET_MAX_CHARS) {
Some((idx, _)) => format!("{}... (truncated)", &body[..idx]),
None => body.to_string(),
};
truncated
.replace('\\', "\\\\")
.replace('\r', "\\r")
.replace('\n', "\\n")
.replace('\t', "\\t")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusClass {
Retryable,
Permanent,
}
#[must_use]
#[inline]
pub(crate) fn classify_status(status: StatusCode) -> StatusClass {
if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
StatusClass::Retryable
} else {
StatusClass::Permanent
}
}
#[must_use]
#[inline]
pub(crate) fn classify_endpoint(method: &Method, path: &str) -> RateLimitClass {
let is_mutation = matches!(*method, Method::POST | Method::PUT | Method::DELETE);
let is_trading_path = path.contains("positions/otc") || path.contains("workingorders/otc");
if is_mutation && is_trading_path {
RateLimitClass::Trading
} else if path.contains("prices/") {
RateLimitClass::Historical
} else {
RateLimitClass::NonTrading
}
}
#[cfg(test)]
mod tests {
use super::{
Arc, Auth, Config, Duration, Instant, KeySlot, RateLimiter, StatusClass, StdMutex,
classify_endpoint, classify_status, redact_key,
};
use crate::application::config::Credentials;
use crate::application::rate_limiter::RateLimitClass;
use reqwest::{Method, StatusCode};
const BASE: &str = "https://demo-api.ig.com/gateway/deal";
#[test]
fn test_classify_endpoint_post_positions_otc_is_trading() {
assert_eq!(
classify_endpoint(&Method::POST, &format!("{BASE}/positions/otc")),
RateLimitClass::Trading
);
}
#[test]
fn test_classify_endpoint_get_prices_is_historical() {
assert_eq!(
classify_endpoint(&Method::GET, &format!("{BASE}/prices/CS.D.EURUSD.MINI.IP")),
RateLimitClass::Historical
);
}
#[test]
fn test_classify_endpoint_get_markets_is_non_trading() {
assert_eq!(
classify_endpoint(&Method::GET, &format!("{BASE}/markets/CS.D.EURUSD.MINI.IP")),
RateLimitClass::NonTrading
);
}
#[test]
fn test_classify_endpoint_put_position_update_is_trading() {
assert_eq!(
classify_endpoint(&Method::PUT, &format!("{BASE}/positions/otc/DIAAAABBBCCC")),
RateLimitClass::Trading
);
}
#[test]
fn test_classify_endpoint_delete_working_order_is_trading() {
assert_eq!(
classify_endpoint(
&Method::DELETE,
&format!("{BASE}/workingorders/otc/DIAAAABBBCCC")
),
RateLimitClass::Trading
);
}
#[test]
fn test_classify_endpoint_get_positions_read_is_non_trading() {
assert_eq!(
classify_endpoint(&Method::GET, &format!("{BASE}/positions")),
RateLimitClass::NonTrading
);
}
#[test]
fn test_classify_status_429_is_retryable() {
assert_eq!(
classify_status(StatusCode::TOO_MANY_REQUESTS),
StatusClass::Retryable
);
}
#[test]
fn test_classify_status_500_is_retryable() {
assert_eq!(
classify_status(StatusCode::INTERNAL_SERVER_ERROR),
StatusClass::Retryable
);
assert_eq!(
classify_status(StatusCode::BAD_GATEWAY),
StatusClass::Retryable
);
assert_eq!(
classify_status(StatusCode::SERVICE_UNAVAILABLE),
StatusClass::Retryable
);
}
#[test]
fn test_classify_status_400_is_permanent() {
assert_eq!(
classify_status(StatusCode::BAD_REQUEST),
StatusClass::Permanent
);
assert_eq!(
classify_status(StatusCode::NOT_FOUND),
StatusClass::Permanent
);
assert_eq!(
classify_status(StatusCode::CONFLICT),
StatusClass::Permanent
);
}
#[test]
fn test_truncate_body_snippet_short_body_is_unchanged() {
let body = r#"{"errorCode":"validation.null-not-allowed.request.epic"}"#;
assert_eq!(super::truncate_body_snippet(body), body);
}
#[test]
fn test_truncate_body_snippet_long_body_is_truncated_on_char_boundary() {
let body = "é".repeat(super::BODY_SNIPPET_MAX_CHARS + 50);
let snippet = super::truncate_body_snippet(&body);
assert!(snippet.ends_with("... (truncated)"));
let kept = snippet.trim_end_matches("... (truncated)");
assert_eq!(kept.chars().count(), super::BODY_SNIPPET_MAX_CHARS);
}
#[test]
fn test_is_auth_endpoint_matches_session_paths_only() {
assert!(super::is_auth_endpoint("/gateway/deal/session"));
assert!(super::is_auth_endpoint("/session"));
assert!(!super::is_auth_endpoint(
"/gateway/deal/markets/CS.D.EURUSD.MINI.IP"
));
}
#[derive(Debug, serde::Deserialize)]
struct RequiredFieldDto {
#[allow(dead_code)]
instrument_type: String,
}
#[tokio::test]
async fn test_parse_response_malformed_body_includes_status_and_snippet() {
use super::HttpClient;
use crate::error::AppError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/markets/CS.D.EURUSD.MINI.IP"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"unexpectedField":"surprise","another":"drifted"}"#,
"application/json",
))
.mount(&server)
.await;
let url = format!("{}/markets/CS.D.EURUSD.MINI.IP", server.uri());
let response = reqwest::Client::new()
.get(&url)
.send()
.await
.expect("request should reach the mock server");
let client = HttpClient::new_lazy(crate::application::config::Config::default())
.expect("lazy HTTP client construction should succeed");
let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
let msg = match result {
Err(AppError::Deserialization(msg)) => msg,
other => panic!("expected AppError::Deserialization, got {other:?}"),
};
assert!(
msg.contains("200"),
"error should carry the HTTP status: {msg}"
);
assert!(
msg.contains("/markets/"),
"error should carry the endpoint URL: {msg}"
);
assert!(
msg.contains("body:"),
"error should carry a body snippet: {msg}"
);
assert!(
msg.contains("unexpectedField"),
"error should include the malformed body snippet: {msg}"
);
}
#[tokio::test]
async fn test_parse_response_session_endpoint_omits_body_snippet() {
use super::HttpClient;
use crate::error::AppError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SECRET: &str = "SUPER-SECRET-OAUTH-TOKEN-VALUE";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
format!(r#"{{"oauthToken":{{"access_token":"{SECRET}"}}}}"#),
"application/json",
))
.mount(&server)
.await;
let url = format!("{}/session", server.uri());
let response = reqwest::Client::new()
.post(&url)
.send()
.await
.expect("request should reach the mock server");
let client = HttpClient::new_lazy(crate::application::config::Config::default())
.expect("lazy HTTP client construction should succeed");
let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
let msg = match result {
Err(AppError::Deserialization(msg)) => msg,
other => panic!("expected AppError::Deserialization, got {other:?}"),
};
assert!(
msg.contains("200"),
"error should carry the HTTP status: {msg}"
);
assert!(
msg.contains("/session"),
"error should carry the endpoint URL: {msg}"
);
assert!(
!msg.contains("body:"),
"session errors must not include a body snippet: {msg}"
);
assert!(
!msg.contains(SECRET),
"session errors must never leak token material: {msg}"
);
}
#[test]
fn test_redact_key_shows_only_a_prefix() {
let key = "6cb0ae4d738dcf918fa47157858fc0ea11290a5b";
let shown = redact_key(key);
assert_eq!(shown, "6cb0ae4d…");
assert!(
!shown.contains("738dcf91"),
"the key body must never be logged"
);
}
#[test]
fn test_redact_key_handles_short_and_empty_keys() {
assert_eq!(redact_key("abc"), "abc…");
assert_eq!(redact_key(""), "…");
}
fn slot(api_key: &str) -> KeySlot {
let config = Arc::new(Config::from_credentials(Credentials::new(
"user".into(),
"pass".into(),
"ACC".into(),
api_key.into(),
)));
KeySlot {
api_key: api_key.to_string(),
rate_limiter: RateLimiter::new(&config.rate_limiter),
auth: Arc::new(Auth::try_new(config).expect("auth builds in tests")),
cooldown_until: Arc::new(StdMutex::new(None)),
}
}
#[test]
fn test_key_slot_cooldown_marks_and_expires() {
let s = slot("key-a");
assert!(!s.in_cooldown(), "a fresh slot is available");
s.mark_exhausted();
assert!(s.in_cooldown(), "a rejected key is skipped");
*s.cooldown_until.lock().expect("lock") = Some(Instant::now() - Duration::from_secs(1));
assert!(
!s.in_cooldown(),
"the key returns to the pool once it refills"
);
}
}