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