use crate::application::auth::{Auth, Session, WebsocketInfo};
use crate::application::config::Config;
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::Arc;
use tracing::{debug, error, warn};
pub struct HttpClient {
auth: Arc<Auth>,
http_client: HttpInternalClient,
config: Arc<Config>,
rate_limiter: RateLimiter,
}
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 rate_limiter = RateLimiter::new(&config.rate_limiter);
let auth = Arc::new(Auth::try_new(config.clone())?);
auth.login().await?;
Ok(Self {
auth,
http_client,
config,
rate_limiter,
})
}
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 rate_limiter = RateLimiter::new(&config.rate_limiter);
let auth = Arc::new(Auth::try_new(config.clone())?);
Ok(Self {
auth,
http_client,
config,
rate_limiter,
})
}
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> {
match self
.request_internal(method.clone(), path, &body, version, extra_headers)
.await
{
Ok(response) => self.parse_response(response).await,
Err(AppError::OAuthTokenExpired) => {
warn!("OAuth token expired, forcing refresh and retrying once");
self.auth.force_refresh().await?;
let response = self
.request_internal(method, path, &body, version, extra_headers)
.await?;
self.parse_response(response).await
}
Err(e) => Err(e),
}
}
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 session = self.auth.get_session().await?;
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 version_owned = version.unwrap_or(1).to_string();
let auth_header_value;
let mut headers = vec![
("X-IG-API-KEY", self.config.credentials.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(
&self.http_client,
&self.rate_limiter,
method,
&url,
headers,
body,
RetryConfig::default(),
)
.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
}
}
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> {
let max_retries = retry_config.max_retries();
let class = classify_endpoint(&method, url);
for attempt in 0..=max_retries {
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")
|| body_text.contains("exceeded-account-allowance")
|| body_text.contains("exceeded-account-trading-allowance")
{
warn!(status = ?status, "allowance rate limit hit");
AppError::RateLimitExceeded
} 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::{StatusClass, classify_endpoint, classify_status};
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}"
);
}
}