ig_client/application/http.rs
1/******************************************************************************
2 Author: Joaquín Béjar García
3 Email: jb@taunais.com
4 Date: 20/10/25
5******************************************************************************/
6
7//! HTTP client and request execution for the IG Markets API.
8//!
9//! This module owns all outbound HTTP I/O: the shared `reqwest` client, rate
10//! limiting, finite retry with backoff, and the automatic token
11//! refresh-and-replay contract. It lives in the `application` layer because it
12//! depends on `Auth`, `Session`, `Config` and `RateLimiter` — the pure `model`
13//! layer must not perform I/O.
14
15use crate::application::auth::{Auth, Session, WebsocketInfo};
16use crate::application::config::{Config, RateLimiterConfig};
17use crate::application::rate_limiter::{RateLimitClass, RateLimiter};
18use crate::constants::USER_AGENT;
19use crate::error::AppError;
20use crate::model::retry::RetryConfig;
21use reqwest::Client as HttpInternalClient;
22use reqwest::{Client, Method, Response, StatusCode};
23use serde::Serialize;
24use serde::de::DeserializeOwned;
25use std::collections::HashMap;
26use std::sync::OnceLock;
27use std::sync::atomic::{AtomicUsize, Ordering};
28use std::sync::{Arc, Mutex as StdMutex};
29use std::time::{Duration, Instant};
30use tracing::{debug, error, warn};
31
32/// Simplified client for IG Markets API with automatic authentication
33///
34/// This client handles all authentication complexity internally, including:
35/// - Initial login
36/// - OAuth token refresh
37/// - Re-authentication when tokens expire
38/// - Account switching
39/// - Rate limiting for all API requests
40pub struct HttpClient {
41 auth: Arc<Auth>,
42 http_client: HttpInternalClient,
43 config: Arc<Config>,
44 /// One entry per API key in the pool. `pool[0]` owns the same [`Auth`] and
45 /// [`RateLimiter`] as the fields above, so a single-key configuration keeps
46 /// the historical behaviour exactly.
47 pool: Vec<KeySlot>,
48 /// The account every request is issued against, with the budget that
49 /// belongs to it.
50 ///
51 /// Kept outside the sessions on purpose. On v3 the account is not a property
52 /// of the session at all — an OAuth token identifies the client and the
53 /// account travels in `IG-ACCOUNT-ID` per request — so storing it per
54 /// session would scatter one value across N keys and lose it on every
55 /// re-login. One shared value also makes a switch atomic: a request either
56 /// sees the old account or the new one, never a half-switched pool.
57 selected: Arc<StdMutex<SelectedAccount>>,
58 /// The slot whose session this client exposes, and the one trading is
59 /// pinned to.
60 ///
61 /// Starts at 0 and moves whenever that key turns out to be unusable — at
62 /// construction, or later, on the lazy path where the first login only
63 /// happens when a request arrives. Pointing `auth`, `ws_info` and order
64 /// traffic at a key that cannot log in would break exactly the paths that
65 /// have no second key to fall back on.
66 primary: AtomicUsize,
67 /// Round-robin starting point for slot selection.
68 ///
69 /// Without it every caller scans the pool from index 0, so concurrent
70 /// requests pile onto the first key while the rest sit idle. Advancing it
71 /// per selection spreads equally-available keys evenly.
72 cursor: AtomicUsize,
73}
74
75/// The account in force and the account-wide budget metered against it.
76///
77/// They travel together because they change together: pointing the client at a
78/// different account must also point it at that account's bucket, or the new
79/// account would be paced against the previous one's remaining allowance.
80struct SelectedAccount {
81 id: String,
82 limiter: RateLimiter,
83}
84
85/// One API key of the pool, with the session and pacing budget that belong to it.
86///
87/// IG meters its non-trading allowance **per API key**, so each key needs both
88/// its own session (`CST` / `X-SECURITY-TOKEN` are issued per key) and its own
89/// rate limiter. Sharing either across keys would defeat the point: a single
90/// bucket would pace the whole pool at one key's rate.
91struct KeySlot {
92 api_key: String,
93 auth: Arc<Auth>,
94 rate_limiter: RateLimiter,
95 /// Set when IG rejects this key with an allowance error; the slot is skipped
96 /// until the instant passes, so the pool stops handing work to a key that
97 /// has already told us it is empty.
98 cooldown_until: Arc<StdMutex<Option<Instant>>>,
99}
100
101/// How long a key is skipped after IG rejects it for exceeding its allowance.
102///
103/// The observed bucket refills at roughly its sustained rate, so a minute is
104/// enough for a saturated key to become useful again without parking it for so
105/// long that the pool shrinks under sustained load.
106const KEY_COOLDOWN: Duration = Duration::from_secs(60);
107
108/// The two budgets every physical request has to pass.
109///
110/// IG meters an allowance per API key *and* one per account, and a pool draws
111/// on both at once: the key's own bucket and the account bucket its keys share.
112/// Both are charged per HTTP request actually sent — logins, token refreshes
113/// and retries included — because that is what IG counts.
114#[derive(Clone, Copy)]
115pub struct Pacing<'a> {
116 /// The budget of the API key serving this request.
117 pub key: &'a RateLimiter,
118 /// The budget shared by every key of the account, when one is configured.
119 pub account: Option<&'a RateLimiter>,
120}
121
122impl<'a> Pacing<'a> {
123 /// Pacing against one key only, with no account-wide budget.
124 #[must_use]
125 pub const fn key_only(key: &'a RateLimiter) -> Self {
126 Self { key, account: None }
127 }
128
129 /// Takes an account token, if an account budget is configured.
130 ///
131 /// Called immediately before the send and never earlier: taken at selection
132 /// time it would be held across a wait of up to a full period, letting
133 /// permits pile up and burst past the ceiling once the waits resolve.
134 ///
135 /// Non-trading and historical traffic charge the **same** bucket, because
136 /// IG's per-account non-trading allowance is one budget: giving each class
137 /// its own account bucket would let `/prices` and market data each run to
138 /// 30/min and together exceed it. Trading does not charge it at all — IG
139 /// meters order traffic against a separate trading allowance, and pacing it
140 /// against the non-trading ceiling would throttle orders behind bulk reads.
141 async fn charge_account(&self, class: RateLimitClass) {
142 if class == RateLimitClass::Trading {
143 return;
144 }
145 if let Some(account) = self.account {
146 // One flat bucket: the class is deliberately collapsed.
147 account.wait_for(RateLimitClass::NonTrading).await;
148 }
149 }
150}
151
152/// IG's documented non-trading ceiling for one account.
153///
154/// Measurement never reached it — four keys sustained 32/min with no rejection —
155/// but it is the published limit, so the pool paces below it rather than
156/// discovering it in production.
157const ACCOUNT_MAX_REQUESTS_PER_MINUTE: u32 = 30;
158
159/// The window [`ACCOUNT_MAX_REQUESTS_PER_MINUTE`] is measured over.
160const ACCOUNT_PERIOD_SECONDS: u64 = 60;
161
162/// Renders an API key for logs as its first eight characters.
163///
164/// Enough to tell the pool's keys apart when reading a trace, never enough to
165/// use: an API key is a secret and must not reach the logs in full.
166fn redact_key(api_key: &str) -> String {
167 let head: String = api_key.chars().take(8).collect();
168 format!("{head}…")
169}
170
171impl KeySlot {
172 /// Whether this slot is currently skipped because IG rejected its key.
173 fn in_cooldown(&self) -> bool {
174 let guard = match self.cooldown_until.lock() {
175 Ok(g) => g,
176 // A poisoned mutex only ever held an `Option<Instant>`, so treating
177 // the slot as available is safe and keeps the pool usable.
178 Err(poisoned) => poisoned.into_inner(),
179 };
180 guard.is_some_and(|until| Instant::now() < until)
181 }
182
183 /// When this slot's cooldown ends, if it is in one.
184 fn cooldown_deadline(&self) -> Option<Instant> {
185 let guard = match self.cooldown_until.lock() {
186 Ok(g) => g,
187 Err(poisoned) => poisoned.into_inner(),
188 };
189 guard.filter(|&until| Instant::now() < until)
190 }
191
192 /// Marks the key as exhausted for [`KEY_COOLDOWN`].
193 fn mark_exhausted(&self) {
194 let until = Instant::now() + KEY_COOLDOWN;
195 match self.cooldown_until.lock() {
196 Ok(mut g) => *g = Some(until),
197 Err(poisoned) => *poisoned.into_inner() = Some(until),
198 }
199 }
200}
201
202impl HttpClient {
203 /// Creates a new client and performs initial authentication
204 ///
205 /// # Arguments
206 /// * `config` - Configuration containing credentials and API settings
207 ///
208 /// # Returns
209 /// * `Ok(Client)` - Authenticated client ready to use
210 /// * `Err(AppError)` - If authentication fails
211 ///
212 /// # Errors
213 /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
214 /// be built (e.g. the system TLS backend fails to initialize), or any
215 /// [`AppError`] surfaced by the initial [`Auth::login`] call.
216 pub async fn new(config: Config) -> Result<Self, AppError> {
217 let config = Arc::new(config);
218
219 // Create HTTP client and rate limiter first
220 let http_client = HttpInternalClient::builder()
221 .user_agent(USER_AGENT)
222 .build()?;
223 // Build the pool first: every slot owns a single-key `Config`, so the
224 // client's own `Auth` is the primary slot's rather than one built from the raw
225 // config, whose `api_key` may be the whole comma-separated list.
226 let account_limiter = Self::build_account_limiter(&config.credentials.account_id);
227 let selected = Arc::new(StdMutex::new(SelectedAccount {
228 id: config.credentials.account_id.clone(),
229 limiter: account_limiter.clone(),
230 }));
231 let (auth, pool) = Self::build_pool(&config, &account_limiter)?;
232
233 // Log in on the first key that will have us: an allowance rejection on
234 // one key says nothing about the others, so construction rotates too
235 // rather than failing on slot 0. Whichever key answered becomes the
236 // primary, so the session this client exposes is one that exists.
237 let primary = Self::login_any(&pool).await?;
238 let auth = pool
239 .get(primary)
240 .map_or_else(|| auth.clone(), |slot| slot.auth.clone());
241
242 Ok(Self {
243 auth,
244 http_client,
245 config,
246 pool,
247 selected,
248 primary: AtomicUsize::new(primary),
249 cursor: AtomicUsize::new(0),
250 })
251 }
252
253 /// Creates a new client without performing initial authentication
254 ///
255 /// # Errors
256 /// Returns `AppError::Network` if the HTTP client cannot be constructed.
257 pub fn new_lazy(config: Config) -> Result<Self, AppError> {
258 let config = Arc::new(config);
259
260 // Create HTTP client and rate limiter first
261 let http_client = HttpInternalClient::builder()
262 .user_agent(USER_AGENT)
263 .build()?;
264 // Same as `new`: the client's `Auth` is the pool's first slot, never an
265 // `Auth` built from a config whose `api_key` is the whole pool list.
266 let account_limiter = Self::build_account_limiter(&config.credentials.account_id);
267 let selected = Arc::new(StdMutex::new(SelectedAccount {
268 id: config.credentials.account_id.clone(),
269 limiter: account_limiter.clone(),
270 }));
271 let (auth, pool) = Self::build_pool(&config, &account_limiter)?;
272
273 Ok(Self {
274 auth,
275 http_client,
276 config,
277 pool,
278 selected,
279 primary: AtomicUsize::new(0),
280 cursor: AtomicUsize::new(0),
281 })
282 }
283
284 /// Builds one [`KeySlot`] per API key declared in the configuration.
285 ///
286 /// Every slot — the first included — gets a `Config` carrying **only its own
287 /// key**. The first slot used to reuse the caller's `Auth`, which had been
288 /// built from the original config: with a pool configured, that `Auth`
289 /// carried the whole comma-separated list as its `api_key` and sent it as
290 /// one 3 KB `X-IG-API-KEY` header, which IG answers with 403. The list is a
291 /// pool specification, never a credential.
292 ///
293 /// Each key's session and its data requests share one limiter, because IG
294 /// meters the login against the same per-key allowance as everything else.
295 ///
296 /// # Errors
297 /// Returns [`AppError::Network`] if a key's [`Auth`] cannot be built.
298 /// # Returns
299 ///
300 /// The pool and the first slot's [`Auth`], which the client adopts as its
301 /// own. Returning it here is what guarantees the client never holds an
302 /// `Auth` built from the raw multi-key config.
303 fn build_pool(
304 config: &Arc<Config>,
305 account_limiter: &RateLimiter,
306 ) -> Result<(Arc<Auth>, Vec<KeySlot>), AppError> {
307 let keys = config.credentials.api_keys();
308 // An empty list means the value held no usable key; keep the raw value
309 // so the failure surfaces as IG rejecting it, not as an empty pool.
310 let keys = if keys.is_empty() {
311 vec![config.credentials.api_key.clone()]
312 } else {
313 keys
314 };
315
316 let mut first_auth: Option<Arc<Auth>> = None;
317 let mut pool = Vec::with_capacity(keys.len());
318 for key in &keys {
319 let mut key_config = (**config).clone();
320 key_config.credentials.api_key = key.clone();
321 let key_config = Arc::new(key_config);
322
323 let key_limiter = RateLimiter::new(&key_config.rate_limiter);
324 let key_auth = Arc::new(Auth::with_limiters(
325 key_config,
326 key_limiter.clone(),
327 account_limiter.clone(),
328 )?);
329 if first_auth.is_none() {
330 first_auth = Some(key_auth.clone());
331 }
332 pool.push(KeySlot {
333 api_key: key.clone(),
334 rate_limiter: key_limiter,
335 auth: key_auth,
336 cooldown_until: Arc::new(StdMutex::new(None)),
337 });
338 }
339
340 if pool.len() > 1 {
341 debug!(keys = pool.len(), "API key pool enabled");
342 }
343
344 // `keys` is non-empty by construction above, so the loop ran at least
345 // once and `first_auth` is set; the fallback keeps this total without a
346 // panic.
347 let auth = match first_auth {
348 Some(auth) => auth,
349 None => Arc::new(Auth::with_limiters(
350 config.clone(),
351 RateLimiter::new(&config.rate_limiter),
352 account_limiter.clone(),
353 )?),
354 };
355 Ok((auth, pool))
356 }
357
358 /// Builds the pool-wide budget that models IG's per-account ceiling.
359 ///
360 /// Modelled directly as IG documents it — 30 requests per 60 seconds — not
361 /// derived from the per-key configuration: the account ceiling is a property
362 /// of the account, and scaling it by the configured period made a shorter
363 /// period silently raise it. The burst is one, so the allowance is spent
364 /// evenly instead of discharging thirty requests at once and then stalling.
365 ///
366 /// Shared per process **and per account**: every `HttpClient` built for the
367 /// same `account_id` gets the same bucket, so two clients in one service do
368 /// not each pace to the full ceiling.
369 ///
370 /// **It does not coordinate across processes.** Two services authenticating
371 /// the same IG account hold two buckets and together exceed the ceiling.
372 /// Enforcing it across services needs a distributed limiter, or an account
373 /// per service — which is the arrangement this codebase actually uses.
374 fn build_account_limiter(account_id: &str) -> RateLimiter {
375 static ACCOUNT_LIMITERS: OnceLock<StdMutex<HashMap<String, RateLimiter>>> = OnceLock::new();
376
377 let registry = ACCOUNT_LIMITERS.get_or_init(|| StdMutex::new(HashMap::new()));
378 let mut guard = match registry.lock() {
379 Ok(g) => g,
380 Err(poisoned) => poisoned.into_inner(),
381 };
382 guard
383 .entry(account_id.to_string())
384 .or_insert_with(|| {
385 RateLimiter::new(&RateLimiterConfig {
386 max_requests: ACCOUNT_MAX_REQUESTS_PER_MINUTE,
387 period_seconds: ACCOUNT_PERIOD_SECONDS,
388 burst_size: 1,
389 })
390 })
391 .clone()
392 }
393
394 /// Logs in on the first key of the pool that accepts us.
395 ///
396 /// A key whose allowance is exhausted rejects the login, which says nothing
397 /// about the other keys; failing construction on slot 0 would throw the
398 /// whole pool away over one spent key.
399 ///
400 /// # Returns
401 ///
402 /// The index of the slot that authenticated. The caller adopts it as the
403 /// client's primary: leaving `auth` on a key that could not log in would
404 /// point streaming, `ws_info` and trading at the exhausted key.
405 ///
406 /// # Errors
407 /// Returns the last key's error when no key could authenticate.
408 async fn login_any(pool: &[KeySlot]) -> Result<usize, AppError> {
409 let mut last: Option<AppError> = None;
410 for (index, slot) in pool.iter().enumerate() {
411 match slot.auth.login().await {
412 Ok(_) => return Ok(index),
413 Err(e @ AppError::ApiKeyAllowanceExceeded) => {
414 slot.mark_exhausted();
415 warn!(
416 key = %redact_key(&slot.api_key),
417 "API key allowance exhausted at login, trying the next key"
418 );
419 last = Some(e);
420 }
421 Err(e) => return Err(e),
422 }
423 }
424 Err(last.unwrap_or(AppError::ApiKeyAllowanceExceeded))
425 }
426
427 /// Reserves a token from a usable key, waiting only if every key is empty.
428 ///
429 /// This is the proactive half of the pool: a key is left before it is
430 /// rejected, not after. It returns the index of a slot whose limiter has
431 /// already given up one token, so the caller owes exactly one request and
432 /// must not pace it again.
433 ///
434 /// Trading never spreads. Order traffic is metered against the account, so
435 /// rotating buys nothing, and keeping it on one key keeps a position's
436 /// requests inside one session — which is what makes a reconciliation
437 /// possible. Trading therefore always uses the primary slot and does not
438 /// touch the cursor.
439 ///
440 /// For everything else, selection starts at a shared round-robin cursor so
441 /// concurrent callers walk the pool from different points instead of piling
442 /// onto the first key. It runs in two passes so that serving one request
443 /// never authenticates the whole pool: first the keys that already hold a
444 /// session, then — only if none of those had a token — a single key that
445 /// still has to log in. When nothing can serve, the pool waits on every
446 /// candidate at once and takes whichever refills first.
447 ///
448 /// # Returns
449 ///
450 /// The reserved slot's index, or `None` when every candidate was already
451 /// tried for this request.
452 ///
453 /// # Errors
454 ///
455 /// Returns whatever [`Auth::get_session`] reports when the chosen key
456 /// cannot authenticate, unless that is a per-key allowance rejection: that
457 /// one parks the key and moves on, because another key can still serve.
458 async fn reserve_slot(
459 &self,
460 class: RateLimitClass,
461 tried: &[usize],
462 ) -> Result<Option<usize>, AppError> {
463 // Trading stays pinned to one slot, so two consecutive orders always
464 // travel on the same key and the same session.
465 if class == RateLimitClass::Trading {
466 let primary = self.primary_index();
467 if tried.contains(&primary) {
468 return Ok(None);
469 }
470 self.pool[primary].rate_limiter.reserve(class).await;
471 self.pool[primary].auth.get_session().await?;
472 return Ok(Some(primary));
473 }
474
475 let len = self.pool.len();
476 let start = self.cursor.fetch_add(1, Ordering::Relaxed);
477
478 let candidates: Vec<usize> = (0..len)
479 .map(|offset| start.wrapping_add(offset) % len)
480 .filter(|i| !tried.contains(i))
481 .collect();
482 if candidates.is_empty() {
483 return Ok(None);
484 }
485
486 // Pass 1: keys that can send without logging in first.
487 for &i in &candidates {
488 let slot = &self.pool[i];
489 if slot.in_cooldown() || !slot.auth.has_ready_session().await {
490 continue;
491 }
492 if slot.rate_limiter.try_reserve(class) {
493 // An already-authenticated key is just as valid a primary as one
494 // that had to log in. Promoting only in pass 2 left trading and
495 // streaming pointing at a parked primary whenever the fallback
496 // happened to hold a session already.
497 self.promote_primary_if_needed(i);
498 return Ok(Some(i));
499 }
500 }
501
502 // Pass 2: at most one key is authenticated, and only when no ready key
503 // had a token. A login that hits this key's allowance parks it and the
504 // next candidate is tried, rather than burning three backoffs here.
505 for &i in &candidates {
506 let slot = &self.pool[i];
507 if slot.in_cooldown() || slot.auth.has_ready_session().await {
508 continue;
509 }
510 match slot.auth.get_session().await {
511 Ok(_) => {}
512 Err(AppError::ApiKeyAllowanceExceeded) if self.pool.len() > 1 => {
513 slot.mark_exhausted();
514 warn!(
515 key = %redact_key(&slot.api_key),
516 "API key allowance exhausted during login, trying another key"
517 );
518 continue;
519 }
520 Err(e) => return Err(e),
521 }
522 // This key just proved it can authenticate. If the incumbent primary
523 // cannot, hand it over: on the lazy path this is the only moment the
524 // client learns which key works, and trading and streaming follow
525 // the primary.
526 self.promote_primary_if_needed(i);
527 if slot.rate_limiter.try_reserve(class) {
528 return Ok(Some(i));
529 }
530 // Authenticated but out of tokens: fall through to the wait below
531 // rather than logging in yet another key.
532 break;
533 }
534
535 // Nothing can serve now. Wait on the keys that are not cooling down; if
536 // every one of them is, wait out the shortest cooldown first so the
537 // pool honours it instead of hammering a key IG has already refused.
538 let live: Vec<usize> = candidates
539 .iter()
540 .copied()
541 .filter(|&i| !self.pool[i].in_cooldown())
542 .collect();
543
544 let waiting = if live.is_empty() {
545 if let Some(until) = candidates
546 .iter()
547 .filter_map(|&i| self.pool[i].cooldown_deadline())
548 .min()
549 {
550 let now = Instant::now();
551 if until > now {
552 debug!(
553 wait_ms = (until - now).as_millis(),
554 "every key is cooling down"
555 );
556 tokio::time::sleep(until - now).await;
557 }
558 }
559 candidates
560 } else {
561 live
562 };
563
564 // Prefer keys that already hold a session: if one of those refills
565 // first, serving this request costs no extra login.
566 let mut ready = Vec::with_capacity(waiting.len());
567 for &i in &waiting {
568 if self.pool[i].auth.has_ready_session().await {
569 ready.push(i);
570 }
571 }
572 let waiting = if ready.is_empty() { waiting } else { ready };
573
574 let waits: Vec<_> = waiting
575 .iter()
576 .map(|&i| {
577 let slot = &self.pool[i];
578 Box::pin(async move {
579 slot.rate_limiter.reserve(class).await;
580 i
581 })
582 })
583 .collect();
584
585 let (winner, _, _) = futures::future::select_all(waits).await;
586
587 // The winner's token is already spent, so a login failure here does cost
588 // it. Establishing the session earlier is not possible: which key wins
589 // is only known once one refills.
590 self.pool[winner].auth.get_session().await?;
591 self.promote_primary_if_needed(winner);
592 Ok(Some(winner))
593 }
594
595 /// Gets WebSocket connection information for Lightstreamer, reusing the
596 /// cached session.
597 ///
598 /// Delegates to [`Auth::ws_info`], which returns the cached session when it
599 /// is valid and only logs in when needed.
600 ///
601 /// # Returns
602 /// * `Ok(WebsocketInfo)` - Server endpoint, authentication tokens, and
603 /// account ID for the current session.
604 /// * `Err(AppError)` - If session retrieval (login / refresh) fails.
605 ///
606 /// # Errors
607 /// Returns [`AppError`] when the session cannot be retrieved.
608 pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
609 self.primary_auth().ws_info().await
610 }
611
612 /// Gets WebSocket connection information for Lightstreamer
613 ///
614 /// # Returns
615 /// * `WebsocketInfo` containing server endpoint, authentication tokens, and account ID
616 #[deprecated(
617 note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
618 )]
619 pub async fn get_ws_info(&self) -> WebsocketInfo {
620 self.ws_info().await.unwrap_or_default()
621 }
622
623 /// Makes a GET request
624 pub async fn get<T: DeserializeOwned>(
625 &self,
626 path: &str,
627 version: Option<u8>,
628 ) -> Result<T, AppError> {
629 self.request(Method::GET, path, None::<()>, version).await
630 }
631
632 /// Makes a POST request
633 pub async fn post<B: Serialize, T: DeserializeOwned>(
634 &self,
635 path: &str,
636 body: B,
637 version: Option<u8>,
638 ) -> Result<T, AppError> {
639 self.request(Method::POST, path, Some(body), version).await
640 }
641
642 /// Makes a PUT request
643 pub async fn put<B: Serialize, T: DeserializeOwned>(
644 &self,
645 path: &str,
646 body: B,
647 version: Option<u8>,
648 ) -> Result<T, AppError> {
649 self.request(Method::PUT, path, Some(body), version).await
650 }
651
652 /// Makes a DELETE request
653 pub async fn delete<T: DeserializeOwned>(
654 &self,
655 path: &str,
656 version: Option<u8>,
657 ) -> Result<T, AppError> {
658 self.request(Method::DELETE, path, None::<()>, version)
659 .await
660 }
661
662 /// Makes a POST request with _method: DELETE header
663 ///
664 /// This is required by IG API for closing positions, as they don't support
665 /// DELETE requests with a body. Instead, they use POST with a special header.
666 ///
667 /// # Arguments
668 /// * `path` - API endpoint path
669 /// * `body` - Request body to send
670 /// * `version` - API version to use
671 ///
672 /// # Returns
673 /// Deserialized response of type T
674 pub async fn post_with_delete_method<B: Serialize, T: DeserializeOwned>(
675 &self,
676 path: &str,
677 body: B,
678 version: Option<u8>,
679 ) -> Result<T, AppError> {
680 // IG requires POST + `_method: DELETE` for position closes; it rejects a
681 // DELETE with a body. Everything else — URL construction, auth headers,
682 // and the 401 refresh-and-replay contract — is identical to a normal
683 // request, so it routes through the same wrapper with one extra header.
684 self.request_with_refresh(
685 Method::POST,
686 path,
687 Some(body),
688 version,
689 &[("_method", "DELETE")],
690 )
691 .await
692 }
693
694 /// Makes a request with custom API version
695 pub async fn request<B: Serialize, T: DeserializeOwned>(
696 &self,
697 method: Method,
698 path: &str,
699 body: Option<B>,
700 version: Option<u8>,
701 ) -> Result<T, AppError> {
702 self.request_with_refresh(method, path, body, version, &[])
703 .await
704 }
705
706 /// Sends a request through the shared builder and applies the token
707 /// refresh-and-replay contract exactly once.
708 ///
709 /// This is the single place the 401 / OAuth-token-expiry handling lives:
710 /// both [`request`](Self::request) and
711 /// [`post_with_delete_method`](Self::post_with_delete_method) route through
712 /// here. On [`AppError::OAuthTokenExpired`] it forces a fresh login and
713 /// replays the request one time. The match arm is not a loop: the replay
714 /// happens exactly once, after which any further failure is returned.
715 async fn request_with_refresh<B: Serialize, T: DeserializeOwned>(
716 &self,
717 method: Method,
718 path: &str,
719 body: Option<B>,
720 version: Option<u8>,
721 extra_headers: &[(&str, &str)],
722 ) -> Result<T, AppError> {
723 // The refresh-and-replay lives in `request_internal`, which knows which
724 // slot's session IG rejected. Refreshing here would always refresh slot
725 // 0, so an expired token on any other key of the pool would be replayed
726 // unchanged and fail again.
727 let response = self
728 .request_internal(method, path, &body, version, extra_headers)
729 .await?;
730 self.parse_response(response).await
731 }
732
733 /// Builds and sends a single HTTP request against the IG API.
734 ///
735 /// Constructs the URL, assembles the common headers (API key, content type,
736 /// version) plus the session auth headers (OAuth `Bearer` or v2
737 /// `CST` / `X-SECURITY-TOKEN`), appends any `extra_headers` (e.g. IG's
738 /// `_method: DELETE` for position closes), and dispatches through
739 /// [`make_http_request`] with the finite default retry policy. It performs
740 /// no token refresh — that is the caller's job via
741 /// [`request_with_refresh`](Self::request_with_refresh).
742 async fn request_internal<B: Serialize>(
743 &self,
744 method: Method,
745 path: &str,
746 body: &Option<B>,
747 version: Option<u8>,
748 extra_headers: &[(&str, &str)],
749 ) -> Result<Response, AppError> {
750 let url = if path.starts_with("http") {
751 path.to_string()
752 } else {
753 let path = path.trim_start_matches('/');
754 format!("{}/{}", self.config.rest_api.base_url, path)
755 };
756
757 let class = classify_endpoint(&method, path);
758
759 // Only non-trading REST rotates. Order placement, amendment and closure
760 // stay on one key: they are metered against the account, not the key, so
761 // moving them buys nothing and would spread order traffic over sessions
762 // that a reconciliation has to tell apart afterwards.
763 let may_rotate = class == RateLimitClass::NonTrading && self.pool.len() > 1;
764
765 let mut tried: Vec<usize> = Vec::with_capacity(self.pool.len());
766 // One replay after a forced refresh, never a loop of them.
767 let mut replayed = false;
768 // The slot a replay must go back to. Re-entering the selector instead
769 // would let the round-robin hand the replay to a *different* key, so
770 // the session that was just refreshed would go unused and a second
771 // invalidated key would burn the single replay.
772 let mut replay_on: Option<usize> = None;
773
774 loop {
775 let idx = if let Some(pinned) = replay_on.take() {
776 // Same key, and it still has to pay for its token.
777 self.pool[pinned].rate_limiter.reserve(class).await;
778 pinned
779 } else if let Some(selected) = self.reserve_slot(class, &tried).await? {
780 selected
781 } else {
782 // Every key has been tried for this request. The last attempt's
783 // error was returned below, so reaching here means the pool ran
784 // out of candidates without one; report the key-level rejection.
785 return Err(AppError::ApiKeyAllowanceExceeded);
786 };
787 if !tried.contains(&idx) {
788 tried.push(idx);
789 }
790 let slot = &self.pool[idx];
791
792 // With another key left to try, a rejected request is cheaper to
793 // move than to wait out this one's backoff, so the per-request retry
794 // budget is dropped and rotation handles it.
795 let rotate = may_rotate && tried.len() < self.pool.len();
796 let retry = if rotate {
797 RetryConfig {
798 max_retry_count: Some(0),
799 retry_delay_secs: None,
800 }
801 } else {
802 RetryConfig::default()
803 };
804
805 let result = self
806 .request_on_slot(slot, &method, &url, body, version, extra_headers, retry)
807 .await;
808
809 match result {
810 // Per-key allowance: this key is spent. Mark it either way, so a
811 // key IG has just refused is not handed the next request even
812 // when there is nowhere left to rotate for this one.
813 Err(AppError::ApiKeyAllowanceExceeded) => {
814 slot.mark_exhausted();
815 if !rotate {
816 return Err(AppError::ApiKeyAllowanceExceeded);
817 }
818 warn!(
819 key = %redact_key(&slot.api_key),
820 of = self.pool.len(),
821 "API key allowance exhausted, rotating to another key"
822 );
823 }
824 // The session IG rejected belongs to this slot, so the refresh
825 // has to happen on that slot - not on the client's own `auth`,
826 // which is the primary and may be a different key entirely.
827 //
828 // Both 401 shapes are recoverable the same way. OAuth says
829 // `oauth-token-invalid` and is obvious; a v2 session instead
830 // just stops being accepted - IG invalidates CST /
831 // X-SECURITY-TOKEN when the account is switched or the same
832 // account authenticates elsewhere - and says nothing the client
833 // can pattern-match. Until now only the OAuth case re-logged in,
834 // so a v2 session that IG had killed was reused forever: the
835 // local clock still considered it valid (v2 lasts six hours), so
836 // nothing refreshed it, and every request returned 401 without
837 // ever attempting to authenticate again.
838 Err(AppError::OAuthTokenExpired | AppError::Unauthorized) if !replayed => {
839 warn!(
840 key = %redact_key(&slot.api_key),
841 "session rejected by IG, re-authenticating this key and replaying once"
842 );
843 slot.auth.force_refresh().await?;
844 replayed = true;
845 // Pin the replay to this slot: it is the one whose session
846 // was just re-established.
847 replay_on = Some(idx);
848 }
849 // Account and trading allowances belong to the account every key
850 // authenticates, and a bare 429 does not say which budget ran
851 // out. None of them justify burning another key.
852 other => return other,
853 }
854 }
855 }
856
857 /// Issues one request through a specific key slot: its session, its API key
858 /// and its own rate-limiter budget.
859 ///
860 /// The caller has already reserved this slot's token, so the send does not
861 /// pace itself again. The session is resolved *before* that token is put on
862 /// the wire, so a failed login costs only the login request it made — never
863 /// an extra token for a data request that is never sent.
864 #[allow(clippy::too_many_arguments)]
865 async fn request_on_slot<B: Serialize>(
866 &self,
867 slot: &KeySlot,
868 method: &Method,
869 url: &str,
870 body: &Option<B>,
871 version: Option<u8>,
872 extra_headers: &[(&str, &str)],
873 retry: RetryConfig,
874 ) -> Result<Response, AppError> {
875 let session = slot.auth.get_session().await?;
876
877 // Account and its budget are read together, so a request never pairs one
878 // account's header with another account's bucket.
879 let (selected_account, account_limiter) = self.selected_account();
880
881 let version_owned = version.unwrap_or(1).to_string();
882 let auth_header_value;
883
884 // Borrow from the slot and the owned `session`, both of which outlive
885 // this function, so no api_key / cst / token clone is needed to build
886 // the header tuples.
887 let mut headers = vec![
888 ("X-IG-API-KEY", slot.api_key.as_str()),
889 ("Content-Type", "application/json; charset=UTF-8"),
890 ("Accept", "application/json; charset=UTF-8"),
891 ("Version", version_owned.as_str()),
892 ];
893 headers.extend_from_slice(extra_headers);
894
895 if let Some(oauth) = &session.oauth_token {
896 auth_header_value = format!("Bearer {}", oauth.access_token);
897 headers.push(("Authorization", auth_header_value.as_str()));
898 // v3 selects the account per request, and the value comes from the
899 // client's shared state rather than the session: a re-login or a
900 // token refresh rebuilds the session from IG's default account and
901 // would otherwise silently undo a switch.
902 headers.push(("IG-ACCOUNT-ID", selected_account.as_str()));
903 } else if let (Some(cst_val), Some(token_val)) = (&session.cst, &session.x_security_token) {
904 headers.push(("CST", cst_val.as_str()));
905 headers.push(("X-SECURITY-TOKEN", token_val.as_str()));
906 }
907
908 make_http_request_paced(
909 &self.http_client,
910 Pacing {
911 key: &slot.rate_limiter,
912 account: Some(&account_limiter),
913 },
914 method.clone(),
915 url,
916 headers,
917 body,
918 retry,
919 true,
920 )
921 .await
922 }
923
924 /// Deserializes a successful HTTP response body into the target DTO,
925 /// attaching request context when parsing fails.
926 ///
927 /// On a deserialization failure the returned [`AppError::Deserialization`]
928 /// names the endpoint URL, the HTTP status, and the serde error, so DTO
929 /// drift is diagnosable instead of surfacing as a bare serde message.
930 ///
931 /// For non-`/session` endpoints a truncated body snippet is appended to the
932 /// message. The `/session` endpoints are auth-adjacent — their bodies can
933 /// carry credentials / CST / X-SECURITY-TOKEN / OAuth tokens — so their body
934 /// is deliberately never echoed into the error (status + URL + serde error
935 /// only).
936 ///
937 /// # Errors
938 /// Returns [`AppError::Network`] if the body cannot be read, and
939 /// [`AppError::Deserialization`] if the body cannot be parsed into `T`.
940 async fn parse_response<T: DeserializeOwned>(&self, response: Response) -> Result<T, AppError> {
941 let status = response.status();
942 let url = response.url().clone();
943 // Buffer the body once so a parse failure can be reported with context;
944 // `json()` would consume the body and leave nothing to snippet.
945 let text = response.text().await?;
946
947 serde_json::from_str(&text).map_err(|e| {
948 // `/session` bodies are auth-adjacent and may carry tokens: never
949 // echo them. Every other endpoint gets a truncated snippet to help
950 // diagnose DTO drift against the real IG payload.
951 if is_auth_endpoint(url.path()) {
952 AppError::Deserialization(format!("failed to deserialize {url} ({status}): {e}"))
953 } else {
954 let snippet = truncate_body_snippet(&text);
955 AppError::Deserialization(format!(
956 "failed to deserialize {url} ({status}): {e}; body: {snippet}"
957 ))
958 }
959 })
960 }
961
962 /// Switches every subsequent request to a different trading account.
963 ///
964 /// The two authentication models need different work, because they keep the
965 /// account in different places.
966 ///
967 /// On **v3** the account is not part of the session: an OAuth token
968 /// identifies the client and the account is chosen per request through
969 /// `IG-ACCOUNT-ID`. Switching is therefore a single update of the client's
970 /// shared state — no request, no session touched, and cold keys stay cold.
971 /// It survives re-login and refresh for the same reason: nothing rebuilt
972 /// from IG's response can overwrite it.
973 ///
974 /// On **v2** the account lives in the session, and IG re-issues its tokens
975 /// on `PUT /session`. That is per key, so a multi-key pool is refused rather
976 /// than spending one request per key against the allowance the pool exists
977 /// to protect.
978 ///
979 /// Either way the account and its account-wide budget move together and
980 /// atomically: a concurrent request sees the old pair or the new one, never
981 /// a mix.
982 ///
983 /// # Errors
984 ///
985 /// Returns [`AppError::InvalidInput`] when `default_account` is `Some(true)`
986 /// — making an account the login default is a v2 account-management
987 /// operation this client does not perform, and silently ignoring the flag
988 /// would leave the caller believing it happened — and when a v2 client holds
989 /// more than one API key. Otherwise returns whatever
990 /// [`Auth::switch_account`] reports.
991 pub async fn switch_account(
992 &self,
993 account_id: &str,
994 default_account: Option<bool>,
995 ) -> Result<(), AppError> {
996 if default_account == Some(true) {
997 return Err(AppError::InvalidInput(
998 "default_account=true is not supported: making an account the \
999 login default is not performed by this client; pass None or \
1000 Some(false) to switch for this client only"
1001 .to_string(),
1002 ));
1003 }
1004
1005 // Taken from the configuration, not from a cached session: on a lazy
1006 // client nothing has authenticated yet, and asking the session would
1007 // classify a v3 pool as v2 and refuse a switch that costs nothing.
1008 let is_oauth = self.config.api_version.unwrap_or(2) == 3;
1009
1010 if !is_oauth {
1011 if self.pool.len() > 1 {
1012 return Err(AppError::InvalidInput(
1013 "switch_account cannot be applied coherently to a multi-key v2 \
1014 pool: each key holds the account in its own session, so this \
1015 would cost one request per key; use api_version 3, which \
1016 selects the account per request, or build one client per \
1017 account"
1018 .to_string(),
1019 ));
1020 }
1021 // v2 carries the account in the session, so IG has to be told.
1022 self.primary_auth()
1023 .switch_account(account_id, default_account)
1024 .await?;
1025 }
1026
1027 // Both models end here: the account in force, and the bucket metered
1028 // against it, are replaced under one lock.
1029 let limiter = Self::build_account_limiter(account_id);
1030 {
1031 let mut guard = match self.selected.lock() {
1032 Ok(g) => g,
1033 Err(poisoned) => poisoned.into_inner(),
1034 };
1035 guard.id = account_id.to_string();
1036 guard.limiter = limiter;
1037 }
1038 debug!(account = %account_id, oauth = is_oauth, "account switched");
1039 Ok(())
1040 }
1041
1042 /// Gets the current session
1043 pub async fn get_session(&self) -> Result<Session, AppError> {
1044 self.primary_auth().get_session().await
1045 }
1046
1047 /// Logs out
1048 pub async fn logout(&self) -> Result<(), AppError> {
1049 self.primary_auth().logout().await
1050 }
1051
1052 /// Gets Auth reference
1053 ///
1054 /// This is the primary slot's `Auth`, which moves with the primary: the
1055 /// session this exposes is always one that authenticated.
1056 pub fn auth(&self) -> &Auth {
1057 self.primary_auth()
1058 }
1059
1060 /// The account currently in force for every request.
1061 ///
1062 /// Read under the lock and cloned, so a concurrent switch cannot be observed
1063 /// half-applied: a request uses either the previous account with its budget
1064 /// or the new one with its own, never one paired with the other's.
1065 fn selected_account(&self) -> (String, RateLimiter) {
1066 let guard = match self.selected.lock() {
1067 Ok(g) => g,
1068 Err(poisoned) => poisoned.into_inner(),
1069 };
1070 (guard.id.clone(), guard.limiter.clone())
1071 }
1072
1073 /// The `Auth` of the slot currently acting as primary.
1074 fn primary_auth(&self) -> &Arc<Auth> {
1075 let index = self.primary_index();
1076 self.pool.get(index).map_or(&self.auth, |slot| &slot.auth)
1077 }
1078
1079 /// The primary slot's index, clamped to the pool.
1080 fn primary_index(&self) -> usize {
1081 self.primary
1082 .load(Ordering::Relaxed)
1083 .min(self.pool.len().saturating_sub(1))
1084 }
1085
1086 /// Moves the primary onto `index` when the current one cannot serve.
1087 ///
1088 /// The lazy constructors cannot know which key will authenticate — the
1089 /// first login happens when a request arrives — so the primary is settled
1090 /// then: the first slot that proves usable takes over if the incumbent is
1091 /// parked. Without this, `ws_info`, streaming and trading would keep
1092 /// pointing at a key that failed to log in.
1093 fn promote_primary_if_needed(&self, index: usize) {
1094 let current = self.primary_index();
1095 if current == index {
1096 return;
1097 }
1098 let incumbent_usable = self
1099 .pool
1100 .get(current)
1101 .is_some_and(|slot| !slot.in_cooldown());
1102 if !incumbent_usable {
1103 self.primary.store(index, Ordering::Relaxed);
1104 if let Some(slot) = self.pool.get(index) {
1105 debug!(
1106 key = %redact_key(&slot.api_key),
1107 "primary key moved to a key that can authenticate"
1108 );
1109 }
1110 }
1111 }
1112
1113 /// Returns the configuration this HTTP client was built with.
1114 ///
1115 /// `Config`'s `Debug` / `Display` impls redact credentials and the database
1116 /// URL, so the returned value can be rendered that way without leaking
1117 /// secrets. Its `Serialize` impl does **not** redact — never serialize a
1118 /// `Config` into logs, telemetry or an error payload.
1119 #[inline]
1120 #[must_use]
1121 pub fn config(&self) -> &Config {
1122 &self.config
1123 }
1124}
1125
1126/// Makes an HTTP request with automatic rate limiting and retry on rate limit errors
1127///
1128/// This function provides a centralized way to make HTTP requests to the IG Markets API
1129/// with built-in rate limiting and automatic retry logic.
1130///
1131/// # Arguments
1132///
1133/// * `client` - The HTTP client to use for the request
1134/// * `rate_limiter` - Shared rate limiter (borrowed) to pace the request
1135/// * `method` - HTTP method (GET, POST, PUT, DELETE, etc.)
1136/// * `url` - Full URL to request
1137/// * `headers` - Vector of (header_name, header_value) tuples
1138/// * `body` - Optional request body (will be serialized to JSON)
1139/// * `retry_config` - Retry configuration (max retries and delay)
1140///
1141/// # Returns
1142///
1143/// * `Ok(Response)` - Successful HTTP response
1144/// * `Err(AppError)` - Error if request fails (excluding rate limit errors which are retried)
1145///
1146/// Retry is always finite: transient failures (429, 5xx, and IG allowance
1147/// rate limits) are retried with exponential backoff up to
1148/// `retry_config.max_retries()`; everything else fails fast. The 401
1149/// token-refresh path is handled by the caller, not here.
1150///
1151/// # Example
1152///
1153/// ```ignore
1154/// use ig_client::application::http::make_http_request;
1155/// use ig_client::model::retry::RetryConfig;
1156/// use reqwest::{Client, Method};
1157///
1158/// let client = Client::new();
1159/// let rate_limiter = RateLimiter::new(&config);
1160/// let headers = vec![
1161/// ("X-IG-API-KEY", "your-api-key"),
1162/// ("Content-Type", "application/json"),
1163/// ];
1164///
1165/// // Finite defaults (DEFAULT_MAX_RETRIES retries, exponential backoff)
1166/// let response = make_http_request(
1167/// &client,
1168/// &rate_limiter,
1169/// Method::GET,
1170/// "https://demo-api.ig.com/gateway/deal/markets/EPIC",
1171/// headers.clone(),
1172/// &None::<()>,
1173/// RetryConfig::default(),
1174/// ).await?;
1175///
1176/// // Maximum 3 retries with a 5 second base delay
1177/// let response = make_http_request(
1178/// &client,
1179/// &rate_limiter,
1180/// Method::GET,
1181/// "https://demo-api.ig.com/gateway/deal/markets/EPIC",
1182/// headers,
1183/// &None::<()>,
1184/// RetryConfig::with_max_retries_and_delay(3, 5),
1185/// ).await?;
1186/// ```
1187pub async fn make_http_request<B: Serialize>(
1188 client: &Client,
1189 rate_limiter: &RateLimiter,
1190 method: Method,
1191 url: &str,
1192 headers: Vec<(&str, &str)>,
1193 body: &Option<B>,
1194 retry_config: RetryConfig,
1195) -> Result<Response, AppError> {
1196 make_http_request_paced(
1197 client,
1198 Pacing::key_only(rate_limiter),
1199 method,
1200 url,
1201 headers,
1202 body,
1203 retry_config,
1204 false,
1205 )
1206 .await
1207}
1208
1209/// Same as [`make_http_request`], but paced against a key budget *and* the
1210/// account budget its keys share.
1211///
1212/// # Errors
1213/// Same as [`make_http_request`].
1214#[allow(clippy::too_many_arguments)]
1215pub async fn make_http_request_with_account<B: Serialize>(
1216 client: &Client,
1217 pacing: Pacing<'_>,
1218 method: Method,
1219 url: &str,
1220 headers: Vec<(&str, &str)>,
1221 body: &Option<B>,
1222 retry_config: RetryConfig,
1223) -> Result<Response, AppError> {
1224 make_http_request_paced(
1225 client,
1226 pacing,
1227 method,
1228 url,
1229 headers,
1230 body,
1231 retry_config,
1232 false,
1233 )
1234 .await
1235}
1236
1237/// Same as [`make_http_request`], but told whether the first send already owns
1238/// a token.
1239///
1240/// `reserved = true` means the caller took a cell from `rate_limiter` for this
1241/// request and the first attempt must not take another. Retries always pace
1242/// themselves: each one is a fresh request as far as IG is concerned.
1243#[allow(clippy::too_many_arguments)]
1244pub async fn make_http_request_reserved<B: Serialize>(
1245 client: &Client,
1246 rate_limiter: &RateLimiter,
1247 method: Method,
1248 url: &str,
1249 headers: Vec<(&str, &str)>,
1250 body: &Option<B>,
1251 retry_config: RetryConfig,
1252 reserved: bool,
1253) -> Result<Response, AppError> {
1254 make_http_request_paced(
1255 client,
1256 Pacing::key_only(rate_limiter),
1257 method,
1258 url,
1259 headers,
1260 body,
1261 retry_config,
1262 reserved,
1263 )
1264 .await
1265}
1266
1267/// The one place a request is actually sent, and so the one place both budgets
1268/// are charged.
1269///
1270/// `reserved = true` means the caller already holds this key's token for the
1271/// first send. The account budget is never pre-reserved: it is taken here,
1272/// immediately before each send, so logins, refreshes and every retry pay it
1273/// too.
1274///
1275/// # Errors
1276/// Same as [`make_http_request`].
1277#[allow(clippy::too_many_arguments)]
1278pub async fn make_http_request_paced<B: Serialize>(
1279 client: &Client,
1280 pacing: Pacing<'_>,
1281 method: Method,
1282 url: &str,
1283 headers: Vec<(&str, &str)>,
1284 body: &Option<B>,
1285 retry_config: RetryConfig,
1286 reserved: bool,
1287) -> Result<Response, AppError> {
1288 let rate_limiter = pacing.key;
1289 let max_retries = retry_config.max_retries();
1290
1291 // Pace this request against the bucket for its endpoint class (trading /
1292 // historical / non-trading) so trading calls never queue behind bulk
1293 // non-trading traffic. The class is derived purely from the method + URL.
1294 let class = classify_endpoint(&method, url);
1295
1296 // Bounded loop: `attempt` ranges over [0, max_retries]. Attempt 0 is the
1297 // first try; each further attempt is a retry. This can never loop forever.
1298 for attempt in 0..=max_retries {
1299 // Pace this request against its class bucket before sending, unless the
1300 // caller already reserved the token for this first send. Reserving and
1301 // then waiting again would spend two cells on one request and halve the
1302 // effective rate. Every retry is a *new* request to IG and pays its own
1303 // token; the limiter is shared by reference and each governor bucket is
1304 // internally `Arc`-backed, so no lock guard is held across this await.
1305 if attempt > 0 || !reserved {
1306 rate_limiter.wait_for(class).await;
1307 }
1308
1309 // Last gate before the wire: the account budget is charged per physical
1310 // request, so a retry costs another token just as a first send does.
1311 pacing.charge_account(class).await;
1312
1313 debug!(%method, %url, class = ?class, "http request");
1314
1315 // Build request
1316 let mut request = client.request(method.clone(), url);
1317
1318 // Add headers
1319 for (name, value) in &headers {
1320 request = request.header(*name, *value);
1321 }
1322
1323 // Add body if present
1324 if let Some(b) = body {
1325 request = request.json(b);
1326 }
1327
1328 // Send request
1329 let response = request.send().await?;
1330 let status = response.status();
1331 debug!(status = ?status, "http response");
1332
1333 if status.is_success() {
1334 return Ok(response);
1335 }
1336
1337 // Classify the failure into a retryable error or an immediate return.
1338 // Body-dependent statuses (401, 403) are handled inline; everything
1339 // else goes through the pure `classify_status` helper.
1340 let retryable_err: AppError = match status {
1341 StatusCode::FORBIDDEN => {
1342 let body_text = response.text().await.unwrap_or_default();
1343
1344 // Historical data allowance is a weekly quota (default 10,000 data points).
1345 // Retrying is pointless — fail fast and let the caller decide.
1346 if body_text.contains("exceeded-account-historical-data-allowance") {
1347 error!("historical data allowance exceeded (weekly quota exhausted)");
1348 return Err(AppError::HistoricalDataAllowanceExceeded {
1349 allowance_expiry: 0,
1350 });
1351 }
1352
1353 // Which allowance ran out decides what the caller may do about
1354 // it, so each one gets its own error instead of collapsing into
1355 // a single "rate limited".
1356 if body_text.contains("exceeded-api-key-allowance") {
1357 // Fail fast rather than spending ~76 s of backoff on a key
1358 // that has just said it is empty. With a pool the caller
1359 // rotates immediately - including on `/session`, where the
1360 // backoff used to be paid before any rotation could happen -
1361 // and with a single key the caller learns sooner.
1362 warn!("api key allowance exceeded");
1363 return Err(AppError::ApiKeyAllowanceExceeded);
1364 } else if body_text.contains("exceeded-account-trading-allowance") {
1365 warn!("account trading allowance exceeded");
1366 return Err(AppError::TradingAllowanceExceeded);
1367 } else if body_text.contains("exceeded-account-allowance") {
1368 // Every key of a pool authenticates this same account, so
1369 // retrying here only spends more of an allowance that is
1370 // already gone. Fail fast and let the caller back off.
1371 warn!("account allowance exceeded");
1372 return Err(AppError::AccountAllowanceExceeded);
1373 } else {
1374 error!(status = ?status, "forbidden");
1375 return Err(AppError::Unexpected(status));
1376 }
1377 }
1378 StatusCode::UNAUTHORIZED => {
1379 let body_text = response.text().await.unwrap_or_default();
1380 if body_text.contains("oauth-token-invalid") {
1381 // Surface to the caller so it can refresh the token and replay.
1382 return Err(AppError::OAuthTokenExpired);
1383 }
1384 error!(status = ?status, "unauthorized");
1385 return Err(AppError::Unauthorized);
1386 }
1387 other => match classify_status(other) {
1388 StatusClass::Retryable => {
1389 // Drain the body (without logging it) so reqwest can return
1390 // the connection to the pool; an undrained body forces the
1391 // connection closed and amplifies load during retry storms.
1392 let _ = response.bytes().await;
1393 if other == StatusCode::TOO_MANY_REQUESTS {
1394 // No body to say which budget ran out, so this stays
1395 // generic: reading it as a per-key rejection would burn
1396 // keys for an account-wide limit.
1397 warn!(status = ?other, "rate limit (429) hit");
1398 AppError::RateLimitExceeded
1399 } else {
1400 warn!(status = ?other, "server error");
1401 AppError::Unexpected(other)
1402 }
1403 }
1404 StatusClass::Permanent => {
1405 error!(status = ?other, "request failed");
1406 return Err(AppError::Unexpected(other));
1407 }
1408 },
1409 };
1410
1411 // We have a transient failure. Retry with exponential backoff unless the
1412 // budget is exhausted (`attempt` here is < max_retries only when retrying).
1413 if attempt < max_retries {
1414 let delay = retry_config.delay_for_attempt(attempt);
1415 let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
1416 warn!(
1417 attempt = attempt.saturating_add(1),
1418 max_retries, delay_ms, "retrying after transient failure"
1419 );
1420 tokio::time::sleep(delay).await;
1421 continue;
1422 }
1423
1424 error!(max_retries, "retries exhausted after transient failures");
1425 return Err(retryable_err);
1426 }
1427
1428 // Unreachable: `0..=max_retries` always yields at least one iteration and the
1429 // final iteration returns. Kept to satisfy the type checker without a panic.
1430 Err(AppError::RateLimitExceeded)
1431}
1432
1433/// Maximum number of characters of a response body echoed into a
1434/// deserialization error message.
1435///
1436/// Long enough to spot the offending field against the real IG payload, short
1437/// enough to keep error messages and logs bounded.
1438const BODY_SNIPPET_MAX_CHARS: usize = 500;
1439
1440/// Returns whether `path` targets the auth-adjacent `/session` endpoint, whose
1441/// response body can carry credentials / session tokens and must never be
1442/// echoed into an error message.
1443#[must_use]
1444#[inline]
1445fn is_auth_endpoint(path: &str) -> bool {
1446 path.contains("/session")
1447}
1448
1449/// Truncates a response body to at most [`BODY_SNIPPET_MAX_CHARS`] characters
1450/// for inclusion in an error message.
1451///
1452/// Truncation is on `char` boundaries so it never splits a UTF-8 code point;
1453/// a truncation marker is appended when the body was longer than the limit.
1454#[must_use]
1455#[inline]
1456fn truncate_body_snippet(body: &str) -> String {
1457 let truncated = match body.char_indices().nth(BODY_SNIPPET_MAX_CHARS) {
1458 // `idx` is the byte offset of the (limit+1)-th char, so `..idx` keeps
1459 // exactly `BODY_SNIPPET_MAX_CHARS` chars on a valid boundary.
1460 Some((idx, _)) => format!("{}... (truncated)", &body[..idx]),
1461 None => body.to_string(),
1462 };
1463 // Keep the snippet on one line: the error string is logged, so raw
1464 // newlines / control characters would fragment the log record and allow
1465 // log-injection-style confusion. Escape CR/LF/TAB to their literal forms.
1466 truncated
1467 .replace('\\', "\\\\")
1468 .replace('\r', "\\r")
1469 .replace('\n', "\\n")
1470 .replace('\t', "\\t")
1471}
1472
1473/// Classification of an HTTP status code for retry decisions.
1474///
1475/// Body-dependent statuses (401, 403) are handled separately in
1476/// [`make_http_request`]; this covers the status-only decisions.
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1478pub(crate) enum StatusClass {
1479 /// Transient failure: retry with backoff.
1480 Retryable,
1481 /// Permanent failure: return immediately.
1482 Permanent,
1483}
1484
1485/// Classifies a non-success HTTP status as transient (retryable) or permanent.
1486///
1487/// Transient: `429 Too Many Requests` and any `5xx` server error. Everything
1488/// else (client errors other than 429) is permanent and fails fast.
1489#[must_use]
1490#[inline]
1491pub(crate) fn classify_status(status: StatusCode) -> StatusClass {
1492 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
1493 StatusClass::Retryable
1494 } else {
1495 StatusClass::Permanent
1496 }
1497}
1498
1499/// Classifies an IG endpoint into its `RateLimitClass` from the HTTP method
1500/// and request path (or full URL).
1501///
1502/// Mapping:
1503/// - `POST` / `PUT` / `DELETE` on `positions/otc` or `workingorders/otc`
1504/// (order and position mutations, including position close via
1505/// `POST` + `_method: DELETE`) → `RateLimitClass::Trading`.
1506/// - Any path under `prices/` (historical price fetches) →
1507/// `RateLimitClass::Historical`.
1508/// - Everything else (market data, account queries, sentiment, watchlists,
1509/// working-order / position *reads*, …) → `RateLimitClass::NonTrading`.
1510///
1511/// `path` may be a bare path or a full URL; matching is by path substring, so
1512/// both `positions/otc` and `.../positions/otc/{deal_id}` classify as trading.
1513/// A `GET` on `positions` or `workingorders` is a read and stays non-trading.
1514#[must_use]
1515#[inline]
1516pub(crate) fn classify_endpoint(method: &Method, path: &str) -> RateLimitClass {
1517 let is_mutation = matches!(*method, Method::POST | Method::PUT | Method::DELETE);
1518 let is_trading_path = path.contains("positions/otc") || path.contains("workingorders/otc");
1519
1520 if is_mutation && is_trading_path {
1521 RateLimitClass::Trading
1522 } else if path.contains("prices/") {
1523 RateLimitClass::Historical
1524 } else {
1525 RateLimitClass::NonTrading
1526 }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 use super::{
1532 Arc, Auth, Config, Duration, Instant, KeySlot, RateLimiter, StatusClass, StdMutex,
1533 classify_endpoint, classify_status, redact_key,
1534 };
1535 use crate::application::config::Credentials;
1536 use crate::application::rate_limiter::RateLimitClass;
1537 use reqwest::{Method, StatusCode};
1538
1539 const BASE: &str = "https://demo-api.ig.com/gateway/deal";
1540
1541 #[test]
1542 fn test_classify_endpoint_post_positions_otc_is_trading() {
1543 assert_eq!(
1544 classify_endpoint(&Method::POST, &format!("{BASE}/positions/otc")),
1545 RateLimitClass::Trading
1546 );
1547 }
1548
1549 #[test]
1550 fn test_classify_endpoint_get_prices_is_historical() {
1551 assert_eq!(
1552 classify_endpoint(&Method::GET, &format!("{BASE}/prices/CS.D.EURUSD.MINI.IP")),
1553 RateLimitClass::Historical
1554 );
1555 }
1556
1557 #[test]
1558 fn test_classify_endpoint_get_markets_is_non_trading() {
1559 assert_eq!(
1560 classify_endpoint(&Method::GET, &format!("{BASE}/markets/CS.D.EURUSD.MINI.IP")),
1561 RateLimitClass::NonTrading
1562 );
1563 }
1564
1565 #[test]
1566 fn test_classify_endpoint_put_position_update_is_trading() {
1567 // Position amend: PUT positions/otc/{deal_id}.
1568 assert_eq!(
1569 classify_endpoint(&Method::PUT, &format!("{BASE}/positions/otc/DIAAAABBBCCC")),
1570 RateLimitClass::Trading
1571 );
1572 }
1573
1574 #[test]
1575 fn test_classify_endpoint_delete_working_order_is_trading() {
1576 assert_eq!(
1577 classify_endpoint(
1578 &Method::DELETE,
1579 &format!("{BASE}/workingorders/otc/DIAAAABBBCCC")
1580 ),
1581 RateLimitClass::Trading
1582 );
1583 }
1584
1585 #[test]
1586 fn test_classify_endpoint_get_positions_read_is_non_trading() {
1587 // A GET on positions is a read, not a mutation, so it stays non-trading.
1588 assert_eq!(
1589 classify_endpoint(&Method::GET, &format!("{BASE}/positions")),
1590 RateLimitClass::NonTrading
1591 );
1592 }
1593
1594 #[test]
1595 fn test_classify_status_429_is_retryable() {
1596 assert_eq!(
1597 classify_status(StatusCode::TOO_MANY_REQUESTS),
1598 StatusClass::Retryable
1599 );
1600 }
1601
1602 #[test]
1603 fn test_classify_status_500_is_retryable() {
1604 assert_eq!(
1605 classify_status(StatusCode::INTERNAL_SERVER_ERROR),
1606 StatusClass::Retryable
1607 );
1608 assert_eq!(
1609 classify_status(StatusCode::BAD_GATEWAY),
1610 StatusClass::Retryable
1611 );
1612 assert_eq!(
1613 classify_status(StatusCode::SERVICE_UNAVAILABLE),
1614 StatusClass::Retryable
1615 );
1616 }
1617
1618 #[test]
1619 fn test_classify_status_400_is_permanent() {
1620 assert_eq!(
1621 classify_status(StatusCode::BAD_REQUEST),
1622 StatusClass::Permanent
1623 );
1624 assert_eq!(
1625 classify_status(StatusCode::NOT_FOUND),
1626 StatusClass::Permanent
1627 );
1628 assert_eq!(
1629 classify_status(StatusCode::CONFLICT),
1630 StatusClass::Permanent
1631 );
1632 }
1633
1634 #[test]
1635 fn test_truncate_body_snippet_short_body_is_unchanged() {
1636 let body = r#"{"errorCode":"validation.null-not-allowed.request.epic"}"#;
1637 assert_eq!(super::truncate_body_snippet(body), body);
1638 }
1639
1640 #[test]
1641 fn test_truncate_body_snippet_long_body_is_truncated_on_char_boundary() {
1642 // A multi-byte char repeated past the limit must not be split.
1643 let body = "é".repeat(super::BODY_SNIPPET_MAX_CHARS + 50);
1644 let snippet = super::truncate_body_snippet(&body);
1645 assert!(snippet.ends_with("... (truncated)"));
1646 // The kept prefix is exactly the char limit (each `é` is 2 bytes).
1647 let kept = snippet.trim_end_matches("... (truncated)");
1648 assert_eq!(kept.chars().count(), super::BODY_SNIPPET_MAX_CHARS);
1649 }
1650
1651 #[test]
1652 fn test_is_auth_endpoint_matches_session_paths_only() {
1653 assert!(super::is_auth_endpoint("/gateway/deal/session"));
1654 assert!(super::is_auth_endpoint("/session"));
1655 assert!(!super::is_auth_endpoint(
1656 "/gateway/deal/markets/CS.D.EURUSD.MINI.IP"
1657 ));
1658 }
1659
1660 /// A DTO with a required field, used to force a deserialization failure
1661 /// against an unexpected IG payload shape.
1662 #[derive(Debug, serde::Deserialize)]
1663 struct RequiredFieldDto {
1664 #[allow(dead_code)]
1665 instrument_type: String,
1666 }
1667
1668 #[tokio::test]
1669 async fn test_parse_response_malformed_body_includes_status_and_snippet() {
1670 use super::HttpClient;
1671 use crate::error::AppError;
1672 use wiremock::matchers::{method, path};
1673 use wiremock::{Mock, MockServer, ResponseTemplate};
1674
1675 let server = MockServer::start().await;
1676 // A 200 whose body does not match the target DTO (DTO drift).
1677 Mock::given(method("GET"))
1678 .and(path("/markets/CS.D.EURUSD.MINI.IP"))
1679 .respond_with(ResponseTemplate::new(200).set_body_raw(
1680 r#"{"unexpectedField":"surprise","another":"drifted"}"#,
1681 "application/json",
1682 ))
1683 .mount(&server)
1684 .await;
1685
1686 let url = format!("{}/markets/CS.D.EURUSD.MINI.IP", server.uri());
1687 let response = reqwest::Client::new()
1688 .get(&url)
1689 .send()
1690 .await
1691 .expect("request should reach the mock server");
1692
1693 let client = HttpClient::new_lazy(crate::application::config::Config::default())
1694 .expect("lazy HTTP client construction should succeed");
1695 let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
1696
1697 let msg = match result {
1698 Err(AppError::Deserialization(msg)) => msg,
1699 other => panic!("expected AppError::Deserialization, got {other:?}"),
1700 };
1701 // Status, endpoint, and a body snippet all present.
1702 assert!(
1703 msg.contains("200"),
1704 "error should carry the HTTP status: {msg}"
1705 );
1706 assert!(
1707 msg.contains("/markets/"),
1708 "error should carry the endpoint URL: {msg}"
1709 );
1710 assert!(
1711 msg.contains("body:"),
1712 "error should carry a body snippet: {msg}"
1713 );
1714 assert!(
1715 msg.contains("unexpectedField"),
1716 "error should include the malformed body snippet: {msg}"
1717 );
1718 }
1719
1720 #[tokio::test]
1721 async fn test_parse_response_session_endpoint_omits_body_snippet() {
1722 use super::HttpClient;
1723 use crate::error::AppError;
1724 use wiremock::matchers::{method, path};
1725 use wiremock::{Mock, MockServer, ResponseTemplate};
1726
1727 // A /session body that fails to deserialize into the target DTO but
1728 // carries a token-shaped secret. The error must NOT echo the body.
1729 const SECRET: &str = "SUPER-SECRET-OAUTH-TOKEN-VALUE";
1730 let server = MockServer::start().await;
1731 Mock::given(method("POST"))
1732 .and(path("/session"))
1733 .respond_with(ResponseTemplate::new(200).set_body_raw(
1734 format!(r#"{{"oauthToken":{{"access_token":"{SECRET}"}}}}"#),
1735 "application/json",
1736 ))
1737 .mount(&server)
1738 .await;
1739
1740 let url = format!("{}/session", server.uri());
1741 let response = reqwest::Client::new()
1742 .post(&url)
1743 .send()
1744 .await
1745 .expect("request should reach the mock server");
1746
1747 let client = HttpClient::new_lazy(crate::application::config::Config::default())
1748 .expect("lazy HTTP client construction should succeed");
1749 let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
1750
1751 let msg = match result {
1752 Err(AppError::Deserialization(msg)) => msg,
1753 other => panic!("expected AppError::Deserialization, got {other:?}"),
1754 };
1755 // Status and endpoint are present for diagnosis...
1756 assert!(
1757 msg.contains("200"),
1758 "error should carry the HTTP status: {msg}"
1759 );
1760 assert!(
1761 msg.contains("/session"),
1762 "error should carry the endpoint URL: {msg}"
1763 );
1764 // ...but the auth-adjacent body (and any token in it) is NOT echoed.
1765 assert!(
1766 !msg.contains("body:"),
1767 "session errors must not include a body snippet: {msg}"
1768 );
1769 assert!(
1770 !msg.contains(SECRET),
1771 "session errors must never leak token material: {msg}"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_redact_key_shows_only_a_prefix() {
1777 let key = "6cb0ae4d738dcf918fa47157858fc0ea11290a5b";
1778 let shown = redact_key(key);
1779 assert_eq!(shown, "6cb0ae4d…");
1780 assert!(
1781 !shown.contains("738dcf91"),
1782 "the key body must never be logged"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_redact_key_handles_short_and_empty_keys() {
1788 assert_eq!(redact_key("abc"), "abc…");
1789 assert_eq!(redact_key(""), "…");
1790 }
1791
1792 fn slot(api_key: &str) -> KeySlot {
1793 let config = Arc::new(Config::from_credentials(Credentials::new(
1794 "user".into(),
1795 "pass".into(),
1796 "ACC".into(),
1797 api_key.into(),
1798 )));
1799 KeySlot {
1800 api_key: api_key.to_string(),
1801 rate_limiter: RateLimiter::new(&config.rate_limiter),
1802 auth: Arc::new(Auth::try_new(config).expect("auth builds in tests")),
1803 cooldown_until: Arc::new(StdMutex::new(None)),
1804 }
1805 }
1806
1807 #[test]
1808 fn test_key_slot_cooldown_marks_and_expires() {
1809 let s = slot("key-a");
1810 assert!(!s.in_cooldown(), "a fresh slot is available");
1811
1812 s.mark_exhausted();
1813 assert!(s.in_cooldown(), "a rejected key is skipped");
1814
1815 // Simulate the cooldown having elapsed.
1816 *s.cooldown_until.lock().expect("lock") = Some(Instant::now() - Duration::from_secs(1));
1817 assert!(
1818 !s.in_cooldown(),
1819 "the key returns to the pool once it refills"
1820 );
1821 }
1822}