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