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