Skip to main content

ig_client/application/
rate_limiter.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6
7//! Rate limiter module for controlling API request rates
8//!
9//! This module provides rate limiting functionality using the `governor` crate
10//! to ensure compliance with IG Markets API rate limits.
11//!
12//! # Enforced budget
13//!
14//! `RateLimiter` holds one `governor` token bucket per
15//! `RateLimitClass`, all derived from the single
16//! `RateLimiterConfig` passed to
17//! `RateLimiter::new`:
18//!
19//! - `NonTrading` honors the configured budget
20//!   exactly: `max_requests` requests per `period_seconds`, with `burst_size`
21//!   burst capacity. The per-cell replenishment interval is `period_seconds /
22//!   max_requests`.
23//! - `Trading` uses a stricter, fixed budget derived
24//!   from IG's published per-app trading limit (~1 request/second), independent
25//!   of the configured budget so a permissive config cannot loosen it.
26//! - `Historical` uses its own conservative bucket
27//!   because historical price fetches also draw down a weekly data-point
28//!   allowance.
29//!
30//! The buckets are independent, so bulk non-trading traffic can never queue
31//! order placement behind it.
32
33use crate::application::config::RateLimiterConfig;
34use crate::constants::{
35    DEFAULT_RATE_LIMIT_BURST_SIZE, FALLBACK_RATE_LIMIT_MAX_REQUESTS,
36    HISTORICAL_RATE_LIMIT_PER_SECOND, TRADING_HISTORICAL_BURST_SIZE, TRADING_RATE_LIMIT_PER_SECOND,
37};
38use governor::{
39    Quota, RateLimiter as GovernorRateLimiter,
40    clock::QuantaClock,
41    state::{InMemoryState, NotKeyed},
42};
43use std::num::NonZeroU32;
44use std::sync::Arc;
45use std::time::Duration;
46
47/// Concrete `governor` direct rate limiter used for every class.
48type DirectLimiter = GovernorRateLimiter<NotKeyed, InMemoryState, QuantaClock>;
49
50/// Number of nanoseconds in one second, used when deriving per-second budgets.
51const NANOS_PER_SECOND: u64 = 1_000_000_000;
52
53/// Rate-limit class for an IG endpoint.
54///
55/// IG applies separate budgets to trading and non-trading traffic, and
56/// historical price fetches additionally draw down a weekly data-point
57/// allowance. Each class maps to an independent token bucket inside
58/// `RateLimiter` so one kind of traffic never starves another.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60#[repr(u8)]
61pub enum RateLimitClass {
62    /// Order and position mutations (`positions/otc`, `workingorders/otc`).
63    ///
64    /// Subject to IG's strict per-app trading limit; the most tightly paced
65    /// class.
66    Trading,
67    /// Historical price fetches (endpoints under `prices/`).
68    ///
69    /// Subject to a weekly data-point allowance in addition to a per-second
70    /// rate, so it gets its own conservative bucket.
71    Historical,
72    /// Everything else: market data, account queries, sentiment, watchlists,
73    /// working-order and position *reads*, and so on.
74    ///
75    /// Paced by the configured `RateLimiterConfig` budget.
76    NonTrading,
77}
78
79/// Rate limiter for controlling API request rates
80///
81/// Uses the `governor` crate to implement a token bucket algorithm for rate
82/// limiting API requests, with one independent bucket per `RateLimitClass`.
83/// See the [module documentation](self) for the enforced budget of each class.
84#[derive(Clone)]
85pub struct RateLimiter {
86    /// Bucket for `RateLimitClass::NonTrading` — the configured budget.
87    non_trading: Arc<DirectLimiter>,
88    /// Bucket for `RateLimitClass::Trading` — derived strict trading budget.
89    trading: Arc<DirectLimiter>,
90    /// Bucket for `RateLimitClass::Historical` — derived conservative budget.
91    historical: Arc<DirectLimiter>,
92}
93
94/// Computes the per-cell replenishment interval that honors the configured
95/// budget: `period_seconds / max_requests`.
96///
97/// This is the core fix for issue #48. The previous implementation used the
98/// whole `period_seconds` as the interval and ignored `max_requests` entirely,
99/// so a `{ max_requests: 60, period_seconds: 60 }` config allowed ~1
100/// request/minute instead of 60.
101///
102/// Guards the structurally-invalid zero cases without dividing by zero or
103/// building a zero-length period:
104/// - `max_requests == 0` falls back to [`FALLBACK_RATE_LIMIT_MAX_REQUESTS`]
105///   (one request per period).
106/// - `period_seconds == 0` falls back to a one-second period.
107///
108/// Rounding is toward positive infinity (ceiling division): the per-cell
109/// interval is never shorter than `period / max_requests`, so the enforced rate
110/// never *exceeds* the configured budget for periods that are not evenly
111/// divisible. The result is clamped to at least one nanosecond so the interval
112/// is always non-zero.
113#[must_use]
114#[inline]
115fn replenish_period(config: &RateLimiterConfig) -> Duration {
116    let max_requests = config.max_requests.max(FALLBACK_RATE_LIMIT_MAX_REQUESTS);
117    let period = if config.period_seconds == 0 {
118        Duration::from_secs(1)
119    } else {
120        Duration::from_secs(config.period_seconds)
121    };
122    // `as_nanos` is u128; clamp to u64 for the very large (multi-century) periods
123    // that cannot occur in practice but must not panic.
124    let period_nanos = u64::try_from(period.as_nanos()).unwrap_or(u64::MAX);
125    // Ceiling division: round the interval UP so the derived rate can only be
126    // at or below `max_requests` per period, never above it. `max_requests >= 1`
127    // here, so this can never divide by zero.
128    let per_cell_nanos = period_nanos.div_ceil(u64::from(max_requests)).max(1);
129    Duration::from_nanos(per_cell_nanos)
130}
131
132/// Per-cell replenishment interval for a fixed "requests per second" budget.
133///
134/// Used to derive the trading and historical buckets. Guards `requests_per_second
135/// == 0` by treating it as one, and clamps the result to at least one nanosecond.
136#[must_use]
137#[inline]
138fn per_second_period(requests_per_second: u32) -> Duration {
139    let rps = u64::from(requests_per_second.max(1));
140    Duration::from_nanos((NANOS_PER_SECOND / rps).max(1))
141}
142
143/// Builds a direct `governor` limiter for a per-cell interval and burst size.
144///
145/// Guards both invalid inputs without panicking:
146/// - a zero `burst_size` falls back to [`DEFAULT_RATE_LIMIT_BURST_SIZE`];
147/// - a zero-length `period_per_cell` (structurally unreachable given the callers)
148///   falls back to a one-request-per-second quota via [`Quota::per_second`].
149#[must_use]
150fn build_limiter(period_per_cell: Duration, burst_size: u32) -> Arc<DirectLimiter> {
151    let burst = NonZeroU32::new(burst_size)
152        .or_else(|| NonZeroU32::new(DEFAULT_RATE_LIMIT_BURST_SIZE))
153        .unwrap_or(NonZeroU32::MIN);
154
155    let quota = match Quota::with_period(period_per_cell) {
156        Some(quota) => quota.allow_burst(burst),
157        // Unreachable in practice: `replenish_period` / `per_second_period`
158        // always yield a non-zero interval. Fall back to a safe quota instead of
159        // panicking so the limiter stays functional.
160        None => Quota::per_second(NonZeroU32::MIN).allow_burst(burst),
161    };
162
163    Arc::new(GovernorRateLimiter::direct(quota))
164}
165
166impl RateLimiter {
167    /// Creates a new rate limiter from configuration
168    ///
169    /// The non-trading bucket honors the configured budget exactly: it admits
170    /// `config.max_requests` requests per `config.period_seconds`, with
171    /// `config.burst_size` burst capacity (replenishing one cell every
172    /// `period_seconds / max_requests`). The trading and historical buckets use
173    /// stricter, fixed budgets derived from IG's published limits and are
174    /// independent of the configured budget. See the [module documentation](self).
175    ///
176    /// # Arguments
177    ///
178    /// * `config` - Rate limiter configuration containing max requests, period, and burst size
179    ///
180    /// # Returns
181    ///
182    /// A new `RateLimiter` instance
183    ///
184    /// # Example
185    ///
186    /// ```ignore
187    /// use ig_client::application::config::RateLimiterConfig;
188    /// use ig_client::application::rate_limiter::RateLimiter;
189    ///
190    /// let config = RateLimiterConfig {
191    ///     max_requests: 60,
192    ///     period_seconds: 60,
193    ///     burst_size: 10,
194    /// };
195    ///
196    /// // Non-trading admits 60 requests/minute (one cell per second, +burst).
197    /// let limiter = RateLimiter::new(&config);
198    /// ```
199    #[must_use]
200    pub fn new(config: &RateLimiterConfig) -> Self {
201        let non_trading = build_limiter(replenish_period(config), config.burst_size);
202        let trading = build_limiter(
203            per_second_period(TRADING_RATE_LIMIT_PER_SECOND),
204            TRADING_HISTORICAL_BURST_SIZE,
205        );
206        let historical = build_limiter(
207            per_second_period(HISTORICAL_RATE_LIMIT_PER_SECOND),
208            TRADING_HISTORICAL_BURST_SIZE,
209        );
210
211        Self {
212            non_trading,
213            trading,
214            historical,
215        }
216    }
217
218    /// Returns the underlying limiter for a given rate-limit class.
219    #[inline]
220    fn limiter_for(&self, class: RateLimitClass) -> &DirectLimiter {
221        match class {
222            RateLimitClass::Trading => &self.trading,
223            RateLimitClass::Historical => &self.historical,
224            RateLimitClass::NonTrading => &self.non_trading,
225        }
226    }
227
228    /// Waits until a request in the given class can be made according to its
229    /// rate limit.
230    ///
231    /// Uses `governor`'s async scheduler ([`until_ready`](GovernorRateLimiter::until_ready)):
232    /// the future is parked until a slot is available rather than busy-polling.
233    /// Each class has an independent bucket, so waiting on one class never blocks
234    /// another.
235    ///
236    /// # Example
237    ///
238    /// ```ignore
239    /// use ig_client::application::rate_limiter::RateLimitClass;
240    /// limiter.wait_for(RateLimitClass::Trading).await;
241    /// // Place order here
242    /// ```
243    pub async fn wait_for(&self, class: RateLimitClass) {
244        self.limiter_for(class).until_ready().await;
245    }
246
247    /// Checks if a request in the given class can be made immediately without
248    /// waiting.
249    ///
250    /// # Returns
251    ///
252    /// * `true` if a request can be made immediately
253    /// * `false` if that class's rate limit has been reached
254    #[must_use]
255    pub fn check_for(&self, class: RateLimitClass) -> bool {
256        self.limiter_for(class).check().is_ok()
257    }
258
259    /// Takes one token of `class` if the bucket has one right now.
260    ///
261    /// This is the reservation half of pacing: a `true` return means a cell has
262    /// already been consumed and the caller owes the network exactly one
263    /// request. It must therefore not call [`wait_for`](Self::wait_for) again
264    /// for that same request, or the request costs two tokens and the effective
265    /// rate halves.
266    ///
267    /// # Returns
268    ///
269    /// * `true` — a token was taken; send now.
270    /// * `false` — the bucket is empty; nothing was consumed.
271    #[must_use]
272    pub fn try_reserve(&self, class: RateLimitClass) -> bool {
273        self.limiter_for(class).check().is_ok()
274    }
275
276    /// Waits until this bucket can serve `class`, then takes the token.
277    ///
278    /// Same reservation contract as [`try_reserve`](Self::try_reserve): on
279    /// return, one cell is spent and the caller owes exactly one request.
280    pub async fn reserve(&self, class: RateLimitClass) {
281        self.limiter_for(class).until_ready().await;
282    }
283
284    /// Waits until a non-trading request can be made according to the rate limit
285    ///
286    /// Convenience wrapper over [`wait_for`](Self::wait_for) with
287    /// `RateLimitClass::NonTrading`. Blocks (asynchronously) until the rate
288    /// limiter allows the request to proceed.
289    ///
290    /// # Example
291    ///
292    /// ```ignore
293    /// limiter.wait().await;
294    /// // Make API request here
295    /// ```
296    pub async fn wait(&self) {
297        self.wait_for(RateLimitClass::NonTrading).await;
298    }
299
300    /// Checks if a non-trading request can be made immediately without waiting
301    ///
302    /// Convenience wrapper over [`check_for`](Self::check_for) with
303    /// `RateLimitClass::NonTrading`.
304    ///
305    /// # Returns
306    ///
307    /// * `true` if a request can be made immediately
308    /// * `false` if the rate limit has been reached
309    ///
310    /// # Example
311    ///
312    /// ```ignore
313    /// if limiter.check() {
314    ///     // Make API request
315    /// } else {
316    ///     // Wait or handle rate limit
317    /// }
318    /// ```
319    #[must_use]
320    pub fn check(&self) -> bool {
321        self.check_for(RateLimitClass::NonTrading)
322    }
323}
324
325impl std::fmt::Debug for RateLimiter {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        f.debug_struct("RateLimiter")
328            .field("non_trading", &"GovernorRateLimiter")
329            .field("trading", &"GovernorRateLimiter")
330            .field("historical", &"GovernorRateLimiter")
331            .finish()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_replenish_period_honors_max_requests() {
341        // 60 requests per 60 seconds => one cell every 1 second.
342        let config = RateLimiterConfig {
343            max_requests: 60,
344            period_seconds: 60,
345            burst_size: 3,
346        };
347        assert_eq!(replenish_period(&config), Duration::from_secs(1));
348        // The OLD bug used the whole 60s period as the interval (~1 req/minute);
349        // the interval must be P/N, not P.
350        assert_ne!(replenish_period(&config), Duration::from_secs(60));
351
352        // 4 requests per 12 seconds => one cell every 3 seconds.
353        let config = RateLimiterConfig {
354            max_requests: 4,
355            period_seconds: 12,
356            burst_size: 3,
357        };
358        assert_eq!(replenish_period(&config), Duration::from_secs(3));
359    }
360
361    #[test]
362    fn test_replenish_period_zero_max_requests_falls_back() {
363        // max_requests == 0 is structurally invalid: fall back to one request per
364        // period (10s per cell) rather than dividing by zero.
365        let config = RateLimiterConfig {
366            max_requests: 0,
367            period_seconds: 10,
368            burst_size: 1,
369        };
370        assert_eq!(replenish_period(&config), Duration::from_secs(10));
371    }
372
373    #[test]
374    fn test_replenish_period_zero_period_falls_back() {
375        // period_seconds == 0 falls back to a one-second period: 5 req/s => 200ms.
376        let config = RateLimiterConfig {
377            max_requests: 5,
378            period_seconds: 0,
379            burst_size: 1,
380        };
381        assert_eq!(replenish_period(&config), Duration::from_millis(200));
382    }
383
384    #[test]
385    fn test_per_second_period_matches_rate() {
386        assert_eq!(per_second_period(1), Duration::from_secs(1));
387        assert_eq!(per_second_period(4), Duration::from_millis(250));
388        // Zero is treated as one to avoid dividing by zero.
389        assert_eq!(per_second_period(0), Duration::from_secs(1));
390    }
391
392    #[test]
393    fn test_configured_quota_replenishes_at_max_requests_rate() {
394        // Deterministic (no wall clock): drive the config-derived quota through
395        // governor's FakeRelativeClock. 20 requests / 1 second => one cell every
396        // 50ms. Under the OLD `with_period(period)` bug the interval was the whole
397        // 1s period, so advancing 50ms would NOT replenish — this test pins the fix.
398        use governor::clock::FakeRelativeClock;
399
400        let config = RateLimiterConfig {
401            max_requests: 20,
402            period_seconds: 1,
403            burst_size: 1,
404        };
405        let interval = replenish_period(&config);
406        assert_eq!(interval, Duration::from_millis(50));
407
408        let burst = NonZeroU32::new(config.burst_size).expect("burst is non-zero");
409        let quota = Quota::with_period(interval)
410            .expect("non-zero interval")
411            .allow_burst(burst);
412        let clock = FakeRelativeClock::default();
413        let limiter = GovernorRateLimiter::direct_with_clock(quota, clock.clone());
414
415        // The single burst cell is available, then exhausted.
416        assert!(limiter.check().is_ok());
417        assert!(limiter.check().is_err(), "burst cell must be exhausted");
418
419        // Before a full interval elapses the cell stays denied (the old 1s-per-cell
420        // bug would still be denied here — that is fine — but see the next step).
421        clock.advance(interval / 2);
422        assert!(
423            limiter.check().is_err(),
424            "must not replenish before the configured interval"
425        );
426
427        // After the full 50ms interval exactly one cell replenishes. The old bug
428        // (1s per cell) would still deny here — so this asserts max_requests is honored.
429        clock.advance(interval / 2);
430        assert!(
431            limiter.check().is_ok(),
432            "one cell must replenish after the configured 50ms interval"
433        );
434    }
435
436    #[tokio::test]
437    async fn test_trading_not_blocked_behind_saturated_non_trading() {
438        // Single-cell non-trading bucket that will not replenish for ~60s.
439        let config = RateLimiterConfig {
440            max_requests: 1,
441            period_seconds: 60,
442            burst_size: 1,
443        };
444        let limiter = RateLimiter::new(&config);
445
446        // Saturate the non-trading bucket: first admits, second is denied.
447        assert!(limiter.check_for(RateLimitClass::NonTrading));
448        assert!(
449            !limiter.check_for(RateLimitClass::NonTrading),
450            "non-trading bucket should be saturated"
451        );
452
453        // Trading and historical have independent buckets and still admit.
454        assert!(
455            limiter.check_for(RateLimitClass::Trading),
456            "trading must not be blocked behind a saturated non-trading bucket"
457        );
458        assert!(
459            limiter.check_for(RateLimitClass::Historical),
460            "historical must not be blocked behind a saturated non-trading bucket"
461        );
462    }
463
464    #[tokio::test]
465    async fn test_wait_for_returns_without_parking_when_slot_available() {
466        // A fresh bucket with burst capacity has a permit available, so `wait_for`
467        // resolves immediately via governor's scheduler rather than parking or
468        // polling. Asserted deterministically via permit availability (no wall
469        // clock): `check_for` is true, and the subsequent `wait_for` must not hang.
470        let config = RateLimiterConfig {
471            max_requests: 10,
472            period_seconds: 1,
473            burst_size: 5,
474        };
475        let limiter = RateLimiter::new(&config);
476
477        assert!(
478            limiter.check_for(RateLimitClass::NonTrading),
479            "a burst slot must be available on a fresh bucket"
480        );
481        // Must return without parking because a permit is available; if it hung,
482        // the test would time out rather than pass.
483        limiter.wait_for(RateLimitClass::NonTrading).await;
484    }
485
486    #[test]
487    fn test_check_delegates_to_non_trading() {
488        // `check()` (no class) must observe the same bucket as
489        // `check_for(NonTrading)` so existing callers keep their semantics.
490        let config = RateLimiterConfig {
491            max_requests: 1,
492            period_seconds: 60,
493            burst_size: 1,
494        };
495        let limiter = RateLimiter::new(&config);
496
497        assert!(limiter.check());
498        assert!(
499            !limiter.check(),
500            "check() must delegate to the non-trading bucket"
501        );
502        assert!(
503            !limiter.check_for(RateLimitClass::NonTrading),
504            "check_for(NonTrading) must observe the same saturated bucket as check()"
505        );
506    }
507}