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}