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
385/// Makes an HTTP request with automatic rate limiting and retry on rate limit errors
386///
387/// This function provides a centralized way to make HTTP requests to the IG Markets API
388/// with built-in rate limiting and automatic retry logic.
389///
390/// # Arguments
391///
392/// * `client` - The HTTP client to use for the request
393/// * `rate_limiter` - Shared rate limiter (borrowed) to pace the request
394/// * `method` - HTTP method (GET, POST, PUT, DELETE, etc.)
395/// * `url` - Full URL to request
396/// * `headers` - Vector of (header_name, header_value) tuples
397/// * `body` - Optional request body (will be serialized to JSON)
398/// * `retry_config` - Retry configuration (max retries and delay)
399///
400/// # Returns
401///
402/// * `Ok(Response)` - Successful HTTP response
403/// * `Err(AppError)` - Error if request fails (excluding rate limit errors which are retried)
404///
405/// Retry is always finite: transient failures (429, 5xx, and IG allowance
406/// rate limits) are retried with exponential backoff up to
407/// `retry_config.max_retries()`; everything else fails fast. The 401
408/// token-refresh path is handled by the caller, not here.
409///
410/// # Example
411///
412/// ```ignore
413/// use ig_client::application::http::make_http_request;
414/// use ig_client::model::retry::RetryConfig;
415/// use reqwest::{Client, Method};
416///
417/// let client = Client::new();
418/// let rate_limiter = RateLimiter::new(&config);
419/// let headers = vec![
420/// ("X-IG-API-KEY", "your-api-key"),
421/// ("Content-Type", "application/json"),
422/// ];
423///
424/// // Finite defaults (DEFAULT_MAX_RETRIES retries, exponential backoff)
425/// let response = make_http_request(
426/// &client,
427/// &rate_limiter,
428/// Method::GET,
429/// "https://demo-api.ig.com/gateway/deal/markets/EPIC",
430/// headers.clone(),
431/// &None::<()>,
432/// RetryConfig::default(),
433/// ).await?;
434///
435/// // Maximum 3 retries with a 5 second base delay
436/// let response = make_http_request(
437/// &client,
438/// &rate_limiter,
439/// Method::GET,
440/// "https://demo-api.ig.com/gateway/deal/markets/EPIC",
441/// headers,
442/// &None::<()>,
443/// RetryConfig::with_max_retries_and_delay(3, 5),
444/// ).await?;
445/// ```
446pub async fn make_http_request<B: Serialize>(
447 client: &Client,
448 rate_limiter: &RateLimiter,
449 method: Method,
450 url: &str,
451 headers: Vec<(&str, &str)>,
452 body: &Option<B>,
453 retry_config: RetryConfig,
454) -> Result<Response, AppError> {
455 let max_retries = retry_config.max_retries();
456
457 // Pace this request against the bucket for its endpoint class (trading /
458 // historical / non-trading) so trading calls never queue behind bulk
459 // non-trading traffic. The class is derived purely from the method + URL.
460 let class = classify_endpoint(&method, url);
461
462 // Bounded loop: `attempt` ranges over [0, max_retries]. Attempt 0 is the
463 // first try; each further attempt is a retry. This can never loop forever.
464 for attempt in 0..=max_retries {
465 // Pace this request against its class bucket before sending. The limiter
466 // is shared by reference; each governor bucket is internally `Arc`-backed
467 // and parks the future until a slot is free, so there is no lock guard
468 // held across this await.
469 rate_limiter.wait_for(class).await;
470
471 debug!(%method, %url, class = ?class, "http request");
472
473 // Build request
474 let mut request = client.request(method.clone(), url);
475
476 // Add headers
477 for (name, value) in &headers {
478 request = request.header(*name, *value);
479 }
480
481 // Add body if present
482 if let Some(b) = body {
483 request = request.json(b);
484 }
485
486 // Send request
487 let response = request.send().await?;
488 let status = response.status();
489 debug!(status = ?status, "http response");
490
491 if status.is_success() {
492 return Ok(response);
493 }
494
495 // Classify the failure into a retryable error or an immediate return.
496 // Body-dependent statuses (401, 403) are handled inline; everything
497 // else goes through the pure `classify_status` helper.
498 let retryable_err: AppError = match status {
499 StatusCode::FORBIDDEN => {
500 let body_text = response.text().await.unwrap_or_default();
501
502 // Historical data allowance is a weekly quota (default 10,000 data points).
503 // Retrying is pointless — fail fast and let the caller decide.
504 if body_text.contains("exceeded-account-historical-data-allowance") {
505 error!("historical data allowance exceeded (weekly quota exhausted)");
506 return Err(AppError::HistoricalDataAllowanceExceeded {
507 allowance_expiry: 0,
508 });
509 }
510
511 if body_text.contains("exceeded-api-key-allowance")
512 || body_text.contains("exceeded-account-allowance")
513 || body_text.contains("exceeded-account-trading-allowance")
514 {
515 warn!(status = ?status, "allowance rate limit hit");
516 AppError::RateLimitExceeded
517 } else {
518 error!(status = ?status, "forbidden");
519 return Err(AppError::Unexpected(status));
520 }
521 }
522 StatusCode::UNAUTHORIZED => {
523 let body_text = response.text().await.unwrap_or_default();
524 if body_text.contains("oauth-token-invalid") {
525 // Surface to the caller so it can refresh the token and replay.
526 return Err(AppError::OAuthTokenExpired);
527 }
528 error!(status = ?status, "unauthorized");
529 return Err(AppError::Unauthorized);
530 }
531 other => match classify_status(other) {
532 StatusClass::Retryable => {
533 // Drain the body (without logging it) so reqwest can return
534 // the connection to the pool; an undrained body forces the
535 // connection closed and amplifies load during retry storms.
536 let _ = response.bytes().await;
537 if other == StatusCode::TOO_MANY_REQUESTS {
538 warn!(status = ?other, "rate limit (429) hit");
539 AppError::RateLimitExceeded
540 } else {
541 warn!(status = ?other, "server error");
542 AppError::Unexpected(other)
543 }
544 }
545 StatusClass::Permanent => {
546 error!(status = ?other, "request failed");
547 return Err(AppError::Unexpected(other));
548 }
549 },
550 };
551
552 // We have a transient failure. Retry with exponential backoff unless the
553 // budget is exhausted (`attempt` here is < max_retries only when retrying).
554 if attempt < max_retries {
555 let delay = retry_config.delay_for_attempt(attempt);
556 let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
557 warn!(
558 attempt = attempt.saturating_add(1),
559 max_retries, delay_ms, "retrying after transient failure"
560 );
561 tokio::time::sleep(delay).await;
562 continue;
563 }
564
565 error!(max_retries, "retries exhausted after transient failures");
566 return Err(retryable_err);
567 }
568
569 // Unreachable: `0..=max_retries` always yields at least one iteration and the
570 // final iteration returns. Kept to satisfy the type checker without a panic.
571 Err(AppError::RateLimitExceeded)
572}
573
574/// Maximum number of characters of a response body echoed into a
575/// deserialization error message.
576///
577/// Long enough to spot the offending field against the real IG payload, short
578/// enough to keep error messages and logs bounded.
579const BODY_SNIPPET_MAX_CHARS: usize = 500;
580
581/// Returns whether `path` targets the auth-adjacent `/session` endpoint, whose
582/// response body can carry credentials / session tokens and must never be
583/// echoed into an error message.
584#[must_use]
585#[inline]
586fn is_auth_endpoint(path: &str) -> bool {
587 path.contains("/session")
588}
589
590/// Truncates a response body to at most [`BODY_SNIPPET_MAX_CHARS`] characters
591/// for inclusion in an error message.
592///
593/// Truncation is on `char` boundaries so it never splits a UTF-8 code point;
594/// a truncation marker is appended when the body was longer than the limit.
595#[must_use]
596#[inline]
597fn truncate_body_snippet(body: &str) -> String {
598 let truncated = match body.char_indices().nth(BODY_SNIPPET_MAX_CHARS) {
599 // `idx` is the byte offset of the (limit+1)-th char, so `..idx` keeps
600 // exactly `BODY_SNIPPET_MAX_CHARS` chars on a valid boundary.
601 Some((idx, _)) => format!("{}... (truncated)", &body[..idx]),
602 None => body.to_string(),
603 };
604 // Keep the snippet on one line: the error string is logged, so raw
605 // newlines / control characters would fragment the log record and allow
606 // log-injection-style confusion. Escape CR/LF/TAB to their literal forms.
607 truncated
608 .replace('\\', "\\\\")
609 .replace('\r', "\\r")
610 .replace('\n', "\\n")
611 .replace('\t', "\\t")
612}
613
614/// Classification of an HTTP status code for retry decisions.
615///
616/// Body-dependent statuses (401, 403) are handled separately in
617/// [`make_http_request`]; this covers the status-only decisions.
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub(crate) enum StatusClass {
620 /// Transient failure: retry with backoff.
621 Retryable,
622 /// Permanent failure: return immediately.
623 Permanent,
624}
625
626/// Classifies a non-success HTTP status as transient (retryable) or permanent.
627///
628/// Transient: `429 Too Many Requests` and any `5xx` server error. Everything
629/// else (client errors other than 429) is permanent and fails fast.
630#[must_use]
631#[inline]
632pub(crate) fn classify_status(status: StatusCode) -> StatusClass {
633 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
634 StatusClass::Retryable
635 } else {
636 StatusClass::Permanent
637 }
638}
639
640/// Classifies an IG endpoint into its `RateLimitClass` from the HTTP method
641/// and request path (or full URL).
642///
643/// Mapping:
644/// - `POST` / `PUT` / `DELETE` on `positions/otc` or `workingorders/otc`
645/// (order and position mutations, including position close via
646/// `POST` + `_method: DELETE`) → `RateLimitClass::Trading`.
647/// - Any path under `prices/` (historical price fetches) →
648/// `RateLimitClass::Historical`.
649/// - Everything else (market data, account queries, sentiment, watchlists,
650/// working-order / position *reads*, …) → `RateLimitClass::NonTrading`.
651///
652/// `path` may be a bare path or a full URL; matching is by path substring, so
653/// both `positions/otc` and `.../positions/otc/{deal_id}` classify as trading.
654/// A `GET` on `positions` or `workingorders` is a read and stays non-trading.
655#[must_use]
656#[inline]
657pub(crate) fn classify_endpoint(method: &Method, path: &str) -> RateLimitClass {
658 let is_mutation = matches!(*method, Method::POST | Method::PUT | Method::DELETE);
659 let is_trading_path = path.contains("positions/otc") || path.contains("workingorders/otc");
660
661 if is_mutation && is_trading_path {
662 RateLimitClass::Trading
663 } else if path.contains("prices/") {
664 RateLimitClass::Historical
665 } else {
666 RateLimitClass::NonTrading
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::{StatusClass, classify_endpoint, classify_status};
673 use crate::application::rate_limiter::RateLimitClass;
674 use reqwest::{Method, StatusCode};
675
676 const BASE: &str = "https://demo-api.ig.com/gateway/deal";
677
678 #[test]
679 fn test_classify_endpoint_post_positions_otc_is_trading() {
680 assert_eq!(
681 classify_endpoint(&Method::POST, &format!("{BASE}/positions/otc")),
682 RateLimitClass::Trading
683 );
684 }
685
686 #[test]
687 fn test_classify_endpoint_get_prices_is_historical() {
688 assert_eq!(
689 classify_endpoint(&Method::GET, &format!("{BASE}/prices/CS.D.EURUSD.MINI.IP")),
690 RateLimitClass::Historical
691 );
692 }
693
694 #[test]
695 fn test_classify_endpoint_get_markets_is_non_trading() {
696 assert_eq!(
697 classify_endpoint(&Method::GET, &format!("{BASE}/markets/CS.D.EURUSD.MINI.IP")),
698 RateLimitClass::NonTrading
699 );
700 }
701
702 #[test]
703 fn test_classify_endpoint_put_position_update_is_trading() {
704 // Position amend: PUT positions/otc/{deal_id}.
705 assert_eq!(
706 classify_endpoint(&Method::PUT, &format!("{BASE}/positions/otc/DIAAAABBBCCC")),
707 RateLimitClass::Trading
708 );
709 }
710
711 #[test]
712 fn test_classify_endpoint_delete_working_order_is_trading() {
713 assert_eq!(
714 classify_endpoint(
715 &Method::DELETE,
716 &format!("{BASE}/workingorders/otc/DIAAAABBBCCC")
717 ),
718 RateLimitClass::Trading
719 );
720 }
721
722 #[test]
723 fn test_classify_endpoint_get_positions_read_is_non_trading() {
724 // A GET on positions is a read, not a mutation, so it stays non-trading.
725 assert_eq!(
726 classify_endpoint(&Method::GET, &format!("{BASE}/positions")),
727 RateLimitClass::NonTrading
728 );
729 }
730
731 #[test]
732 fn test_classify_status_429_is_retryable() {
733 assert_eq!(
734 classify_status(StatusCode::TOO_MANY_REQUESTS),
735 StatusClass::Retryable
736 );
737 }
738
739 #[test]
740 fn test_classify_status_500_is_retryable() {
741 assert_eq!(
742 classify_status(StatusCode::INTERNAL_SERVER_ERROR),
743 StatusClass::Retryable
744 );
745 assert_eq!(
746 classify_status(StatusCode::BAD_GATEWAY),
747 StatusClass::Retryable
748 );
749 assert_eq!(
750 classify_status(StatusCode::SERVICE_UNAVAILABLE),
751 StatusClass::Retryable
752 );
753 }
754
755 #[test]
756 fn test_classify_status_400_is_permanent() {
757 assert_eq!(
758 classify_status(StatusCode::BAD_REQUEST),
759 StatusClass::Permanent
760 );
761 assert_eq!(
762 classify_status(StatusCode::NOT_FOUND),
763 StatusClass::Permanent
764 );
765 assert_eq!(
766 classify_status(StatusCode::CONFLICT),
767 StatusClass::Permanent
768 );
769 }
770
771 #[test]
772 fn test_truncate_body_snippet_short_body_is_unchanged() {
773 let body = r#"{"errorCode":"validation.null-not-allowed.request.epic"}"#;
774 assert_eq!(super::truncate_body_snippet(body), body);
775 }
776
777 #[test]
778 fn test_truncate_body_snippet_long_body_is_truncated_on_char_boundary() {
779 // A multi-byte char repeated past the limit must not be split.
780 let body = "é".repeat(super::BODY_SNIPPET_MAX_CHARS + 50);
781 let snippet = super::truncate_body_snippet(&body);
782 assert!(snippet.ends_with("... (truncated)"));
783 // The kept prefix is exactly the char limit (each `é` is 2 bytes).
784 let kept = snippet.trim_end_matches("... (truncated)");
785 assert_eq!(kept.chars().count(), super::BODY_SNIPPET_MAX_CHARS);
786 }
787
788 #[test]
789 fn test_is_auth_endpoint_matches_session_paths_only() {
790 assert!(super::is_auth_endpoint("/gateway/deal/session"));
791 assert!(super::is_auth_endpoint("/session"));
792 assert!(!super::is_auth_endpoint(
793 "/gateway/deal/markets/CS.D.EURUSD.MINI.IP"
794 ));
795 }
796
797 /// A DTO with a required field, used to force a deserialization failure
798 /// against an unexpected IG payload shape.
799 #[derive(Debug, serde::Deserialize)]
800 struct RequiredFieldDto {
801 #[allow(dead_code)]
802 instrument_type: String,
803 }
804
805 #[tokio::test]
806 async fn test_parse_response_malformed_body_includes_status_and_snippet() {
807 use super::HttpClient;
808 use crate::error::AppError;
809 use wiremock::matchers::{method, path};
810 use wiremock::{Mock, MockServer, ResponseTemplate};
811
812 let server = MockServer::start().await;
813 // A 200 whose body does not match the target DTO (DTO drift).
814 Mock::given(method("GET"))
815 .and(path("/markets/CS.D.EURUSD.MINI.IP"))
816 .respond_with(ResponseTemplate::new(200).set_body_raw(
817 r#"{"unexpectedField":"surprise","another":"drifted"}"#,
818 "application/json",
819 ))
820 .mount(&server)
821 .await;
822
823 let url = format!("{}/markets/CS.D.EURUSD.MINI.IP", server.uri());
824 let response = reqwest::Client::new()
825 .get(&url)
826 .send()
827 .await
828 .expect("request should reach the mock server");
829
830 let client = HttpClient::new_lazy(crate::application::config::Config::default())
831 .expect("lazy HTTP client construction should succeed");
832 let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
833
834 let msg = match result {
835 Err(AppError::Deserialization(msg)) => msg,
836 other => panic!("expected AppError::Deserialization, got {other:?}"),
837 };
838 // Status, endpoint, and a body snippet all present.
839 assert!(
840 msg.contains("200"),
841 "error should carry the HTTP status: {msg}"
842 );
843 assert!(
844 msg.contains("/markets/"),
845 "error should carry the endpoint URL: {msg}"
846 );
847 assert!(
848 msg.contains("body:"),
849 "error should carry a body snippet: {msg}"
850 );
851 assert!(
852 msg.contains("unexpectedField"),
853 "error should include the malformed body snippet: {msg}"
854 );
855 }
856
857 #[tokio::test]
858 async fn test_parse_response_session_endpoint_omits_body_snippet() {
859 use super::HttpClient;
860 use crate::error::AppError;
861 use wiremock::matchers::{method, path};
862 use wiremock::{Mock, MockServer, ResponseTemplate};
863
864 // A /session body that fails to deserialize into the target DTO but
865 // carries a token-shaped secret. The error must NOT echo the body.
866 const SECRET: &str = "SUPER-SECRET-OAUTH-TOKEN-VALUE";
867 let server = MockServer::start().await;
868 Mock::given(method("POST"))
869 .and(path("/session"))
870 .respond_with(ResponseTemplate::new(200).set_body_raw(
871 format!(r#"{{"oauthToken":{{"access_token":"{SECRET}"}}}}"#),
872 "application/json",
873 ))
874 .mount(&server)
875 .await;
876
877 let url = format!("{}/session", server.uri());
878 let response = reqwest::Client::new()
879 .post(&url)
880 .send()
881 .await
882 .expect("request should reach the mock server");
883
884 let client = HttpClient::new_lazy(crate::application::config::Config::default())
885 .expect("lazy HTTP client construction should succeed");
886 let result: Result<RequiredFieldDto, AppError> = client.parse_response(response).await;
887
888 let msg = match result {
889 Err(AppError::Deserialization(msg)) => msg,
890 other => panic!("expected AppError::Deserialization, got {other:?}"),
891 };
892 // Status and endpoint are present for diagnosis...
893 assert!(
894 msg.contains("200"),
895 "error should carry the HTTP status: {msg}"
896 );
897 assert!(
898 msg.contains("/session"),
899 "error should carry the endpoint URL: {msg}"
900 );
901 // ...but the auth-adjacent body (and any token in it) is NOT echoed.
902 assert!(
903 !msg.contains("body:"),
904 "session errors must not include a body snippet: {msg}"
905 );
906 assert!(
907 !msg.contains(SECRET),
908 "session errors must never leak token material: {msg}"
909 );
910 }
911}