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    /// Waits until a non-trading request can be made according to the rate limit
260    ///
261    /// Convenience wrapper over [`wait_for`](Self::wait_for) with
262    /// `RateLimitClass::NonTrading`. Blocks (asynchronously) until the rate
263    /// limiter allows the request to proceed.
264    ///
265    /// # Example
266    ///
267    /// ```ignore
268    /// limiter.wait().await;
269    /// // Make API request here
270    /// ```
271    pub async fn wait(&self) {
272        self.wait_for(RateLimitClass::NonTrading).await;
273    }
274
275    /// Checks if a non-trading request can be made immediately without waiting
276    ///
277    /// Convenience wrapper over [`check_for`](Self::check_for) with
278    /// `RateLimitClass::NonTrading`.
279    ///
280    /// # Returns
281    ///
282    /// * `true` if a request can be made immediately
283    /// * `false` if the rate limit has been reached
284    ///
285    /// # Example
286    ///
287    /// ```ignore
288    /// if limiter.check() {
289    ///     // Make API request
290    /// } else {
291    ///     // Wait or handle rate limit
292    /// }
293    /// ```
294    #[must_use]
295    pub fn check(&self) -> bool {
296        self.check_for(RateLimitClass::NonTrading)
297    }
298}
299
300impl std::fmt::Debug for RateLimiter {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct("RateLimiter")
303            .field("non_trading", &"GovernorRateLimiter")
304            .field("trading", &"GovernorRateLimiter")
305            .field("historical", &"GovernorRateLimiter")
306            .finish()
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_replenish_period_honors_max_requests() {
316        // 60 requests per 60 seconds => one cell every 1 second.
317        let config = RateLimiterConfig {
318            max_requests: 60,
319            period_seconds: 60,
320            burst_size: 3,
321        };
322        assert_eq!(replenish_period(&config), Duration::from_secs(1));
323        // The OLD bug used the whole 60s period as the interval (~1 req/minute);
324        // the interval must be P/N, not P.
325        assert_ne!(replenish_period(&config), Duration::from_secs(60));
326
327        // 4 requests per 12 seconds => one cell every 3 seconds.
328        let config = RateLimiterConfig {
329            max_requests: 4,
330            period_seconds: 12,
331            burst_size: 3,
332        };
333        assert_eq!(replenish_period(&config), Duration::from_secs(3));
334    }
335
336    #[test]
337    fn test_replenish_period_zero_max_requests_falls_back() {
338        // max_requests == 0 is structurally invalid: fall back to one request per
339        // period (10s per cell) rather than dividing by zero.
340        let config = RateLimiterConfig {
341            max_requests: 0,
342            period_seconds: 10,
343            burst_size: 1,
344        };
345        assert_eq!(replenish_period(&config), Duration::from_secs(10));
346    }
347
348    #[test]
349    fn test_replenish_period_zero_period_falls_back() {
350        // period_seconds == 0 falls back to a one-second period: 5 req/s => 200ms.
351        let config = RateLimiterConfig {
352            max_requests: 5,
353            period_seconds: 0,
354            burst_size: 1,
355        };
356        assert_eq!(replenish_period(&config), Duration::from_millis(200));
357    }
358
359    #[test]
360    fn test_per_second_period_matches_rate() {
361        assert_eq!(per_second_period(1), Duration::from_secs(1));
362        assert_eq!(per_second_period(4), Duration::from_millis(250));
363        // Zero is treated as one to avoid dividing by zero.
364        assert_eq!(per_second_period(0), Duration::from_secs(1));
365    }
366
367    #[test]
368    fn test_configured_quota_replenishes_at_max_requests_rate() {
369        // Deterministic (no wall clock): drive the config-derived quota through
370        // governor's FakeRelativeClock. 20 requests / 1 second => one cell every
371        // 50ms. Under the OLD `with_period(period)` bug the interval was the whole
372        // 1s period, so advancing 50ms would NOT replenish — this test pins the fix.
373        use governor::clock::FakeRelativeClock;
374
375        let config = RateLimiterConfig {
376            max_requests: 20,
377            period_seconds: 1,
378            burst_size: 1,
379        };
380        let interval = replenish_period(&config);
381        assert_eq!(interval, Duration::from_millis(50));
382
383        let burst = NonZeroU32::new(config.burst_size).expect("burst is non-zero");
384        let quota = Quota::with_period(interval)
385            .expect("non-zero interval")
386            .allow_burst(burst);
387        let clock = FakeRelativeClock::default();
388        let limiter = GovernorRateLimiter::direct_with_clock(quota, clock.clone());
389
390        // The single burst cell is available, then exhausted.
391        assert!(limiter.check().is_ok());
392        assert!(limiter.check().is_err(), "burst cell must be exhausted");
393
394        // Before a full interval elapses the cell stays denied (the old 1s-per-cell
395        // bug would still be denied here — that is fine — but see the next step).
396        clock.advance(interval / 2);
397        assert!(
398            limiter.check().is_err(),
399            "must not replenish before the configured interval"
400        );
401
402        // After the full 50ms interval exactly one cell replenishes. The old bug
403        // (1s per cell) would still deny here — so this asserts max_requests is honored.
404        clock.advance(interval / 2);
405        assert!(
406            limiter.check().is_ok(),
407            "one cell must replenish after the configured 50ms interval"
408        );
409    }
410
411    #[tokio::test]
412    async fn test_trading_not_blocked_behind_saturated_non_trading() {
413        // Single-cell non-trading bucket that will not replenish for ~60s.
414        let config = RateLimiterConfig {
415            max_requests: 1,
416            period_seconds: 60,
417            burst_size: 1,
418        };
419        let limiter = RateLimiter::new(&config);
420
421        // Saturate the non-trading bucket: first admits, second is denied.
422        assert!(limiter.check_for(RateLimitClass::NonTrading));
423        assert!(
424            !limiter.check_for(RateLimitClass::NonTrading),
425            "non-trading bucket should be saturated"
426        );
427
428        // Trading and historical have independent buckets and still admit.
429        assert!(
430            limiter.check_for(RateLimitClass::Trading),
431            "trading must not be blocked behind a saturated non-trading bucket"
432        );
433        assert!(
434            limiter.check_for(RateLimitClass::Historical),
435            "historical must not be blocked behind a saturated non-trading bucket"
436        );
437    }
438
439    #[tokio::test]
440    async fn test_wait_for_returns_without_parking_when_slot_available() {
441        // A fresh bucket with burst capacity has a permit available, so `wait_for`
442        // resolves immediately via governor's scheduler rather than parking or
443        // polling. Asserted deterministically via permit availability (no wall
444        // clock): `check_for` is true, and the subsequent `wait_for` must not hang.
445        let config = RateLimiterConfig {
446            max_requests: 10,
447            period_seconds: 1,
448            burst_size: 5,
449        };
450        let limiter = RateLimiter::new(&config);
451
452        assert!(
453            limiter.check_for(RateLimitClass::NonTrading),
454            "a burst slot must be available on a fresh bucket"
455        );
456        // Must return without parking because a permit is available; if it hung,
457        // the test would time out rather than pass.
458        limiter.wait_for(RateLimitClass::NonTrading).await;
459    }
460
461    #[test]
462    fn test_check_delegates_to_non_trading() {
463        // `check()` (no class) must observe the same bucket as
464        // `check_for(NonTrading)` so existing callers keep their semantics.
465        let config = RateLimiterConfig {
466            max_requests: 1,
467            period_seconds: 60,
468            burst_size: 1,
469        };
470        let limiter = RateLimiter::new(&config);
471
472        assert!(limiter.check());
473        assert!(
474            !limiter.check(),
475            "check() must delegate to the non-trading bucket"
476        );
477        assert!(
478            !limiter.check_for(RateLimitClass::NonTrading),
479            "check_for(NonTrading) must observe the same saturated bucket as check()"
480        );
481    }
482}