Skip to main content

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}