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