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