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