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::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>,
account_limiter: RateLimiter,
cursor: AtomicUsize,
}
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);
const ACCOUNT_MAX_REQUESTS_PER_MINUTE: u32 = 30;
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 (auth, pool) = Self::build_pool(&config)?;
let account_limiter = Self::build_account_limiter(&config, pool.len());
auth.login().await?;
Ok(Self {
auth,
http_client,
config,
pool,
account_limiter,
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 (auth, pool) = Self::build_pool(&config)?;
let account_limiter = Self::build_account_limiter(&config, pool.len());
Ok(Self {
auth,
http_client,
config,
pool,
account_limiter,
cursor: AtomicUsize::new(0),
})
}
fn build_pool(config: &Arc<Config>) -> 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_rate_limiter(key_config, key_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::try_new(config.clone())?),
};
Ok((auth, pool))
}
fn build_account_limiter(config: &Arc<Config>, keys: usize) -> RateLimiter {
let per_key = config.rate_limiter.max_requests;
let keys = u32::try_from(keys).unwrap_or(u32::MAX);
let aggregate = per_key.saturating_mul(keys);
let period = config.rate_limiter.period_seconds.max(1);
let ceiling = u32::try_from(
u64::from(ACCOUNT_MAX_REQUESTS_PER_MINUTE)
.saturating_mul(period)
.div_ceil(60),
)
.unwrap_or(u32::MAX)
.max(1);
let max_requests = aggregate.min(ceiling);
RateLimiter::new(&RateLimiterConfig {
max_requests,
period_seconds: config.rate_limiter.period_seconds,
burst_size: config.rate_limiter.burst_size.min(max_requests),
})
}
async fn reserve_slot(
&self,
class: RateLimitClass,
tried: &[usize],
) -> Result<Option<usize>, AppError> {
if class == RateLimitClass::Trading {
if tried.contains(&0) {
return Ok(None);
}
self.account_limiter.reserve(class).await;
self.pool[0].rate_limiter.reserve(class).await;
self.pool[0].auth.get_session().await?;
return Ok(Some(0));
}
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);
}
self.account_limiter.reserve(class).await;
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) {
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),
}
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?;
Ok(Some(winner))
}
pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
self.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;
loop {
let Some(idx) = self.reserve_slot(class, &tried).await? else {
return Err(AppError::ApiKeyAllowanceExceeded);
};
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) if !replayed => {
warn!(
key = %redact_key(&slot.api_key),
"OAuth token expired, refreshing this key's session and replaying once"
);
slot.auth.force_refresh().await?;
replayed = true;
tried.pop();
}
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 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", session.account_id.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_reserved(
&self.http_client,
&slot.rate_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> {
self.auth
.switch_account(account_id, default_account)
.await?;
Ok(())
}
pub async fn get_session(&self) -> Result<Session, AppError> {
self.auth.get_session().await
}
pub async fn logout(&self) -> Result<(), AppError> {
self.auth.logout().await
}
pub fn auth(&self) -> &Auth {
&self.auth
}
#[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_reserved(
client,
rate_limiter,
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> {
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;
}
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"
);
}
}