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