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