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}