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}