use reqwest::Client;
use std::{error::Error as StdError, fmt, time::Duration};
const FLEX_BASE_URL: &str =
"https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService";
const USER_AGENT: &str = concat!("ib-flex/", env!("CARGO_PKG_VERSION"));
#[derive(Debug)]
pub enum FlexApiError {
RequestFailed(reqwest::Error),
ApiError(String),
XmlError(String),
StatementNotReady,
InvalidResponse(String),
}
impl fmt::Display for FlexApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RequestFailed(error) => {
write!(f, "HTTP request failed: {}", describe_request_error(error))
}
Self::ApiError(message) => write!(f, "IB API error: {message}"),
Self::XmlError(message) => write!(f, "XML parsing error: {message}"),
Self::StatementNotReady => f.write_str("Statement not ready (try again later)"),
Self::InvalidResponse(message) => write!(f, "Invalid response format: {message}"),
}
}
}
impl StdError for FlexApiError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::RequestFailed(error) => Some(error),
_ => None,
}
}
}
impl From<reqwest::Error> for FlexApiError {
fn from(error: reqwest::Error) -> Self {
Self::RequestFailed(error.without_url())
}
}
pub type Result<T> = std::result::Result<T, FlexApiError>;
#[derive(Debug, Clone)]
pub struct FlexApiClient {
token: String,
base_url: String,
client: Client,
}
impl FlexApiClient {
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
base_url: FLEX_BASE_URL.to_string(),
client: build_http_client(),
}
}
pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
Self {
token: token.into(),
base_url: normalize_base_url(base_url),
client: build_http_client(),
}
}
pub async fn send_request(&self, query_id: &str) -> Result<String> {
let url = format!(
"{}/SendRequest?t={}&q={}&v=3",
self.base_url, self.token, query_id
);
let response = self.client.get(url).send().await?;
let body = response.text().await?;
self.parse_send_request_response(&body)
}
pub async fn get_statement(&self, reference_code: &str) -> Result<String> {
let url = format!(
"{}/GetStatement?t={}&q={}&v=3",
self.base_url, self.token, reference_code
);
let response = self.client.get(url).send().await?;
let body = response.text().await?;
if body.contains("<Status>") {
self.parse_get_statement_response(&body)
} else {
Ok(body)
}
}
pub async fn get_statement_with_retry(
&self,
reference_code: &str,
max_retries: usize,
retry_delay: Duration,
) -> Result<String> {
for attempt in 0..=max_retries {
match self.get_statement(reference_code).await {
Ok(xml) => return Ok(xml),
Err(FlexApiError::StatementNotReady) => {
if attempt < max_retries {
tokio::time::sleep(retry_delay).await;
continue;
} else {
return Err(FlexApiError::StatementNotReady);
}
}
Err(e) => return Err(e),
}
}
unreachable!("Loop should always return within the iteration")
}
fn parse_send_request_response(&self, xml: &str) -> Result<String> {
if let Some(start) = xml.find("<ReferenceCode>") {
if let Some(end) = xml[start..].find("</ReferenceCode>") {
let ref_code = &xml[start + 15..start + end];
return Ok(ref_code.to_string());
}
}
if xml.contains("<Status>Fail</Status>") || xml.contains("<Status>Warn</Status>") {
if let Some(start) = xml.find("<ErrorMessage>") {
if let Some(end) = xml[start..].find("</ErrorMessage>") {
let error = &xml[start + 14..start + end];
return Err(FlexApiError::ApiError(error.to_string()));
}
}
return Err(FlexApiError::ApiError("Unknown error".to_string()));
}
Err(FlexApiError::InvalidResponse(
"Could not parse reference code".to_string(),
))
}
fn parse_get_statement_response(&self, xml: &str) -> Result<String> {
if xml.contains("<ErrorCode>1019</ErrorCode>") {
return Err(FlexApiError::StatementNotReady);
}
if xml.contains("<Status>Fail</Status>") || xml.contains("<Status>Warn</Status>") {
if let Some(start) = xml.find("<ErrorMessage>") {
if let Some(end) = xml[start..].find("</ErrorMessage>") {
let error = &xml[start + 14..start + end];
return Err(FlexApiError::ApiError(error.to_string()));
}
}
return Err(FlexApiError::ApiError("Unknown error".to_string()));
}
Err(FlexApiError::InvalidResponse(
"Unexpected response format".to_string(),
))
}
}
fn build_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(USER_AGENT)
.build()
.expect("Failed to build HTTP client")
}
fn normalize_base_url(base_url: impl Into<String>) -> String {
base_url.into().trim_end_matches('/').to_string()
}
fn describe_request_error(error: &reqwest::Error) -> String {
if let Some(status) = error.status() {
return format!("IB Flex API returned HTTP status {status}");
}
if error.is_timeout() {
return "request timed out while contacting IB Flex API".to_string();
}
if is_dns_error(error) {
return "DNS resolution failed while connecting to IB Flex API".to_string();
}
if error.is_connect() {
return "connection failed while connecting to IB Flex API".to_string();
}
if error.is_body() {
return "failed while reading IB Flex API response body".to_string();
}
if error.is_decode() {
return "failed while decoding IB Flex API response".to_string();
}
"request failed while contacting IB Flex API".to_string()
}
fn is_dns_error(error: &reqwest::Error) -> bool {
error_source_chain_contains(
error,
&["dns error", "failed to lookup address", "could not resolve"],
)
}
fn error_source_chain_contains(error: &dyn StdError, patterns: &[&str]) -> bool {
let mut source = error.source();
while let Some(error) = source {
let message = error.to_string().to_ascii_lowercase();
if patterns.iter().any(|pattern| message.contains(pattern)) {
return true;
}
source = error.source();
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_send_request_success() {
let client = FlexApiClient::new("test_token");
let xml = r#"
<FlexStatementResponse timestamp='01 January, 2025 12:00 AM EDT'>
<Status>Success</Status>
<ReferenceCode>1234567890</ReferenceCode>
<Url>https://example.com</Url>
</FlexStatementResponse>
"#;
let result = client.parse_send_request_response(xml);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "1234567890");
}
#[test]
fn test_parse_send_request_error() {
let client = FlexApiClient::new("test_token");
let xml = r#"
<FlexStatementResponse timestamp='01 January, 2025 12:00 AM EDT'>
<Status>Fail</Status>
<ErrorCode>1003</ErrorCode>
<ErrorMessage>Invalid token</ErrorMessage>
</FlexStatementResponse>
"#;
let result = client.parse_send_request_response(xml);
assert!(result.is_err());
match result {
Err(FlexApiError::ApiError(msg)) => assert_eq!(msg, "Invalid token"),
_ => panic!("Expected ApiError"),
}
}
#[test]
fn test_parse_get_statement_not_ready() {
let client = FlexApiClient::new("test_token");
let xml = r#"
<FlexStatementResponse timestamp='01 January, 2025 12:00 AM EDT'>
<Status>Warn</Status>
<ErrorCode>1019</ErrorCode>
<ErrorMessage>Statement is being generated; please try again shortly</ErrorMessage>
</FlexStatementResponse>
"#;
let result = client.parse_get_statement_response(xml);
assert!(result.is_err());
match result {
Err(FlexApiError::StatementNotReady) => (),
_ => panic!("Expected StatementNotReady"),
}
}
#[test]
fn test_client_creation() {
let client = FlexApiClient::new("my_token");
assert_eq!(client.token, "my_token");
assert_eq!(client.base_url, FLEX_BASE_URL);
}
#[test]
fn test_client_with_custom_url() {
let client = FlexApiClient::with_base_url("my_token", "https://custom.url/");
assert_eq!(client.token, "my_token");
assert_eq!(client.base_url, "https://custom.url");
}
#[tokio::test]
async fn test_request_error_does_not_expose_token_url() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let client = FlexApiClient::with_base_url("super_secret_token", format!("http://{addr}"));
let error = client.send_request("123456").await.unwrap_err();
let message = error.to_string();
let debug = format!("{error:?}");
assert!(message.contains("HTTP request failed"));
assert!(!message.contains("super_secret_token"));
assert!(!message.contains("t="));
assert!(!debug.contains("super_secret_token"));
assert!(!debug.contains("t="));
}
}