ig_client/session/
interface.rs

1use crate::config::Config;
2use crate::error::{AppError, AuthError};
3use crate::utils::rate_limiter::{
4    RateLimitType, RateLimiter, RateLimiterStats, app_non_trading_limiter, create_rate_limiter,
5};
6use chrono::{DateTime, Utc};
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::sync::atomic::{AtomicBool, Ordering};
10use tracing::debug;
11
12/// Timer for managing IG API token expiration and refresh cycles
13///
14/// According to IG API documentation, tokens are initially valid for 6 hours
15/// but get extended up to a maximum of 72 hours while they are in use.
16#[derive(Debug, Clone)]
17pub struct TokenTimer {
18    /// The current expiry time of the token (initially 6 hours from creation)
19    pub expiry: DateTime<Utc>,
20    /// The timestamp when the token was last refreshed
21    pub last_refreshed: DateTime<Utc>,
22    /// The maximum age the token can reach (72 hours from initial creation)
23    pub max_age: DateTime<Utc>,
24}
25
26impl Default for TokenTimer {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl TokenTimer {
33    /// Creates a new TokenTimer with initial 6-hour expiry and 72-hour maximum age
34    ///
35    /// # Returns
36    /// A new TokenTimer instance with expiry set to 6 hours from now and max_age set to 72 hours from now
37    pub fn new() -> Self {
38        let expiry = Utc::now() + chrono::Duration::hours(6);
39        let max_age = Utc::now() + chrono::Duration::hours(72);
40        Self {
41            expiry,
42            last_refreshed: Utc::now(),
43            max_age,
44        }
45    }
46
47    /// Checks if the token is expired based on current time
48    ///
49    /// # Returns
50    /// `true` if either the token expiry time or maximum age has been reached, `false` otherwise
51    pub fn is_expired(&self) -> bool {
52        self.expiry <= Utc::now() || self.max_age <= Utc::now()
53    }
54
55    /// Checks if the token is expired or will expire within the given margin
56    ///
57    /// # Arguments
58    /// * `margin` - The time margin to check before actual expiry
59    ///
60    /// # Returns
61    /// `true` if the token will expire within the margin or has already expired, `false` otherwise
62    pub fn is_expired_w_margin(&self, margin: chrono::Duration) -> bool {
63        self.expiry - margin <= Utc::now() || self.max_age - margin <= Utc::now()
64    }
65
66    /// Refreshes the token timer, extending the expiry time by 6 hours from now
67    ///
68    /// This should be called after each successful API request to extend token validity.
69    /// The expiry time is reset to 6 hours from the current time, but cannot exceed max_age.
70    pub fn refresh(&mut self) {
71        self.expiry = Utc::now() + chrono::Duration::hours(6);
72        self.last_refreshed = Utc::now();
73    }
74}
75
76/// Session information for IG Markets API authentication
77#[derive(Debug, Clone)]
78pub struct IgSession {
79    /// Client Session Token (CST) used for authentication
80    pub cst: String,
81    /// Security token used for authentication
82    pub token: String,
83    /// Account ID associated with the session
84    pub account_id: String,
85    /// Base URL for API requests
86    pub base_url: String,
87    /// Client ID for API requests
88    pub client_id: String,
89    /// Lightstreamer endpoint for API requests
90    pub lightstreamer_endpoint: String,
91    /// API key for API requests
92    pub api_key: String,
93    /// Rate limiter for controlling request rates
94    pub(crate) rate_limiter: Option<Arc<RateLimiter>>,
95    /// Flag to indicate if the session is being used in a concurrent context
96    pub(crate) concurrent_mode: Arc<AtomicBool>,
97    /// Timer for managing token expiration and automatic refresh cycles
98    pub token_timer: Arc<Mutex<TokenTimer>>,
99}
100
101impl IgSession {
102    /// Creates a new session with the given credentials
103    ///
104    /// This is a simplified version for tests and basic usage.
105    /// Uses default values for most fields and a default rate limiter.
106    pub fn new(cst: String, token: String, account_id: String) -> Self {
107        Self {
108            base_url: String::new(),
109            cst,
110            token,
111            client_id: String::new(),
112            account_id,
113            lightstreamer_endpoint: String::new(),
114            api_key: String::new(),
115            rate_limiter: Some(create_rate_limiter(
116                RateLimitType::NonTradingAccount,
117                Some(0.8),
118            )),
119            concurrent_mode: Arc::new(AtomicBool::new(false)),
120            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
121        }
122    }
123
124    /// Creates a new session with the given parameters
125    ///
126    /// This creates a thread-safe session that can be shared across multiple threads.
127    /// The rate limiter is wrapped in an Arc to ensure proper synchronization.
128    #[allow(clippy::too_many_arguments)]
129    pub fn new_with_config(
130        base_url: String,
131        cst: String,
132        security_token: String,
133        client_id: String,
134        account_id: String,
135        lightstreamer_endpoint: String,
136        api_key: String,
137        rate_limit_type: RateLimitType,
138        rate_limit_safety_margin: f64,
139    ) -> Self {
140        // Create a rate limiter with the specified type and safety margin
141        let rate_limiter = create_rate_limiter(rate_limit_type, Some(rate_limit_safety_margin));
142
143        Self {
144            base_url,
145            cst,
146            token: security_token,
147            client_id,
148            account_id,
149            lightstreamer_endpoint,
150            api_key,
151            rate_limiter: Some(rate_limiter),
152            concurrent_mode: Arc::new(AtomicBool::new(false)),
153            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
154        }
155    }
156
157    /// Creates a new session with the given credentials and a rate limiter
158    ///
159    /// This creates a thread-safe session that can be shared across multiple threads.
160    pub fn with_rate_limiter(
161        cst: String,
162        token: String,
163        account_id: String,
164        limit_type: RateLimitType,
165    ) -> Self {
166        Self {
167            cst,
168            token,
169            account_id,
170            base_url: String::new(),
171            client_id: String::new(),
172            lightstreamer_endpoint: String::new(),
173            api_key: String::new(),
174            rate_limiter: Some(create_rate_limiter(limit_type, Some(0.8))),
175            concurrent_mode: Arc::new(AtomicBool::new(false)),
176            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
177        }
178    }
179
180    /// Creates a new session with the given credentials and rate limiter configuration from Config
181    pub fn from_config(cst: String, token: String, account_id: String, config: &Config) -> Self {
182        Self {
183            cst,
184            token,
185            account_id,
186            base_url: String::new(),
187            client_id: String::new(),
188            lightstreamer_endpoint: String::new(),
189            api_key: String::new(),
190            rate_limiter: Some(create_rate_limiter(
191                config.rate_limit_type,
192                Some(config.rate_limit_safety_margin),
193            )),
194            concurrent_mode: Arc::new(AtomicBool::new(false)),
195            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
196        }
197    }
198
199    /// Waits if necessary to respect rate limits before making a request
200    ///
201    /// This method will always use a rate limiter - either the one configured in the session,
202    /// or a default one if none is configured.
203    ///
204    /// This method is thread-safe and can be called from multiple threads concurrently.
205    ///
206    /// # Returns
207    /// * `Ok(())` - If the rate limit is respected
208    /// * `Err(AppError::RateLimitExceeded)` - If the rate limit has been exceeded and cannot be respected
209    pub async fn respect_rate_limit(&self) -> Result<(), AppError> {
210        // Mark that this session is being used in a concurrent context
211        self.concurrent_mode.store(true, Ordering::SeqCst);
212
213        // Get the rate limiter from the session or use a default one
214        let limiter = match &self.rate_limiter {
215            Some(limiter) => limiter.clone(),
216            None => {
217                // This should never happen since we always initialize with a default limiter,
218                // but just in case, use the global app non-trading limiter
219                debug!("No rate limiter configured in session, using default");
220                app_non_trading_limiter()
221            }
222        };
223
224        // Wait if necessary to respect the rate limit
225        limiter.wait().await;
226        Ok(())
227    }
228
229    /// Gets statistics about the current rate limit usage
230    pub async fn get_rate_limit_stats(&self) -> Option<RateLimiterStats> {
231        match &self.rate_limiter {
232            Some(limiter) => Some(limiter.get_stats().await),
233            None => None,
234        }
235    }
236
237    /// Refreshes the token timer to extend token validity
238    /// This should be called after each successful API request
239    pub fn refresh_token_timer(&self) {
240        if let Ok(mut timer) = self.token_timer.lock() {
241            timer.refresh();
242        }
243    }
244}
245
246/// Trait for authenticating with the IG Markets API
247#[async_trait::async_trait]
248pub trait IgAuthenticator: Send + Sync {
249    /// Logs in to the IG Markets API and returns a new session
250    async fn login(&self) -> Result<IgSession, AuthError>;
251    /// Refreshes an existing session with the IG Markets API
252    async fn refresh(&self, session: &IgSession) -> Result<IgSession, AuthError>;
253
254    /// Switches the active account for the current session
255    ///
256    /// # Arguments
257    /// * `session` - The current session
258    /// * `account_id` - The ID of the account to switch to
259    /// * `default_account` - Whether to set this account as the default (optional)
260    ///
261    /// # Returns
262    /// * A new session with the updated account ID
263    async fn switch_account(
264        &self,
265        session: &IgSession,
266        account_id: &str,
267        default_account: Option<bool>,
268    ) -> Result<IgSession, AuthError>;
269
270    /// Attempts to login and switch to the specified account, optionally setting it as the default account.
271    ///
272    /// # Arguments
273    ///
274    /// * `account_id` - A string slice that holds the ID of the account to which the session should switch.
275    /// * `default_account` - An optional boolean parameter. If `Some(true)`, the given account will be marked
276    ///   as the default account for subsequent operations. If `None` or `Some(false)`, the account will not
277    ///   be set as default.
278    ///
279    /// # Returns
280    ///
281    /// This function returns a `Result`:
282    /// * `Ok(IgSession)` - On success, contains an updated `IgSession` object representing the active session
283    ///   state after the switch.
284    /// * `Err(AuthError)` - If the operation fails, returns an `AuthError` containing details about the issue.
285    ///
286    /// # Errors
287    ///
288    /// This function can return `AuthError` in the following scenarios:
289    /// * If the provided `account_id` is invalid or does not exist.
290    /// * If there is a network issue during the login/switch process.
291    /// * If there are authentication or session-related failures.
292    ///
293    /// # Notes
294    ///
295    /// Ensure that the `account_id` is valid and accessible under the authenticated user's account scope.
296    /// Switching accounts may invalidate the previous session if the platform enforces single-session
297    /// restrictions.
298    async fn login_and_switch_account(
299        &self,
300        account_id: &str,
301        default_account: Option<bool>,
302    ) -> Result<IgSession, AuthError>;
303
304    /// Attempts to relogin (if needed) and switch to the specified account.
305    /// This method uses relogin() instead of login() to avoid unnecessary authentication
306    /// when tokens are still valid.
307    ///
308    /// # Arguments
309    /// * `session` - The current session to check for token validity
310    /// * `account_id` - The ID of the account to switch to
311    /// * `default_account` - Whether to set this account as the default (optional)
312    ///
313    /// # Returns
314    /// * `Ok(IgSession)` - On success, contains an updated session for the target account
315    /// * `Err(AuthError)` - If the operation fails
316    async fn relogin_and_switch_account(
317        &self,
318        session: &IgSession,
319        account_id: &str,
320        default_account: Option<bool>,
321    ) -> Result<IgSession, AuthError>;
322
323    /// Re-authenticates only if the current session tokens are expired or close to expiring.
324    /// This method checks the token expiration with a safety margin and only performs login
325    /// if necessary, making it more efficient than always calling login().
326    ///
327    /// # Arguments
328    /// * `session` - The current session to check for token validity
329    ///
330    /// # Returns
331    /// * `Ok(IgSession)` - Either the existing session (if tokens are still valid) or a new session (if re-login was needed)
332    /// * `Err(AuthError)` - If re-authentication fails
333    async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError>;
334}