Skip to main content

projectx_client/
rate_limit.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Provider REST rate-limit configuration and shared admission control.
5
6use std::{collections::VecDeque, fmt, time::Duration};
7
8use parking_lot::Mutex;
9use tokio::time::{Instant, sleep};
10
11use crate::Error;
12
13const DEFAULT_HISTORY_REQUESTS: usize = 50;
14const DEFAULT_HISTORY_WINDOW: Duration = Duration::from_secs(30);
15const DEFAULT_GENERAL_REQUESTS: usize = 200;
16const DEFAULT_GENERAL_WINDOW: Duration = Duration::from_mins(1);
17const MAX_INITIAL_CAPACITY: usize = 1_024;
18/// Shared ceiling for one provider cooldown.
19///
20/// Provider `Retry-After` values are unbounded remote input. The limiter
21/// clamps them to this ceiling so no caller-supplied duration can overflow
22/// the clock into a skipped cooldown, and the REST client bounds the
23/// `retry_after` it parses and reports from the same constant, keeping
24/// admission and `Error::ProviderRateLimited` in lockstep. A provider
25/// cooldown longer than the ceiling is deliberately shortened to it rather
26/// than blocking admission for a duration the caller cannot distinguish
27/// from permanent; the ceiling matches the longest parsed `Retry-After`
28/// the REST client will act on.
29pub(crate) const MAX_COOLDOWN: Duration = Duration::from_hours(24);
30
31/// Identifies one provider REST rate-limit budget.
32#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
33#[non_exhaustive]
34pub enum RateLimitKind {
35    /// The dedicated `/api/History/retrieveBars` budget.
36    History,
37    /// The budget shared by all other authenticated REST endpoints.
38    General,
39}
40
41impl fmt::Display for RateLimitKind {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::History => formatter.write_str("history"),
45            Self::General => formatter.write_str("general"),
46        }
47    }
48}
49
50/// One rolling-window request limit.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52#[non_exhaustive]
53pub struct RateLimit {
54    max_requests: usize,
55    window: Duration,
56}
57
58impl RateLimit {
59    /// Creates a non-zero rolling-window request limit.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::Configuration`] when `max_requests` or `window` is
64    /// zero, or when the duration cannot be represented by a monotonic clock.
65    pub fn new(max_requests: usize, window: Duration) -> Result<Self, Error> {
66        if max_requests == 0
67            || window.is_zero()
68            || std::time::Instant::now().checked_add(window).is_none()
69        {
70            return Err(Error::Configuration(
71                "rate limits require a positive request count and representable window".to_owned(),
72            ));
73        }
74        Ok(Self {
75            max_requests,
76            window,
77        })
78    }
79
80    const fn trusted(max_requests: usize, window: Duration) -> Self {
81        Self {
82            max_requests,
83            window,
84        }
85    }
86
87    /// Returns the maximum admitted requests in the rolling window.
88    #[must_use]
89    pub const fn max_requests(self) -> usize {
90        self.max_requests
91    }
92
93    /// Returns the rolling-window duration.
94    #[must_use]
95    pub const fn window(self) -> Duration {
96        self.window
97    }
98}
99
100/// Rate limits applied to authenticated `ProjectX` REST requests.
101///
102/// [`Default`] uses the provider's documented limits: 50 history requests per
103/// 30 seconds and 200 other authenticated requests per 60 seconds.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105#[non_exhaustive]
106pub struct RateLimitConfig {
107    history: RateLimit,
108    general: RateLimit,
109}
110
111impl RateLimitConfig {
112    /// Creates configuration from dedicated history and general limits.
113    #[must_use]
114    pub const fn new(history: RateLimit, general: RateLimit) -> Self {
115        Self { history, general }
116    }
117
118    /// Returns the history endpoint limit.
119    #[must_use]
120    pub const fn history(self) -> RateLimit {
121        self.history
122    }
123
124    /// Returns the limit for all other authenticated endpoints.
125    #[must_use]
126    pub const fn general(self) -> RateLimit {
127        self.general
128    }
129
130    pub(crate) const fn limit(self, kind: RateLimitKind) -> RateLimit {
131        match kind {
132            RateLimitKind::History => self.history,
133            RateLimitKind::General => self.general,
134        }
135    }
136}
137
138impl Default for RateLimitConfig {
139    fn default() -> Self {
140        Self {
141            history: RateLimit::trusted(DEFAULT_HISTORY_REQUESTS, DEFAULT_HISTORY_WINDOW),
142            general: RateLimit::trusted(DEFAULT_GENERAL_REQUESTS, DEFAULT_GENERAL_WINDOW),
143        }
144    }
145}
146
147pub(crate) struct RateLimits {
148    config: Option<RateLimitConfig>,
149    history: Option<WindowLimiter>,
150    general: Option<WindowLimiter>,
151}
152
153impl RateLimits {
154    pub(crate) fn new(config: Option<RateLimitConfig>) -> Self {
155        let history = config.map(|limits| WindowLimiter::new(limits.history));
156        let general = config.map(|limits| WindowLimiter::new(limits.general));
157        Self {
158            config,
159            history,
160            general,
161        }
162    }
163
164    pub(crate) const fn config(&self) -> Option<RateLimitConfig> {
165        self.config
166    }
167
168    pub(crate) fn limit(&self, kind: RateLimitKind) -> RateLimit {
169        self.config.unwrap_or_default().limit(kind)
170    }
171
172    pub(crate) async fn wait(&self, kind: RateLimitKind) {
173        let Some(limiter) = self.limiter(kind) else {
174            return;
175        };
176        loop {
177            match limiter.try_acquire(Instant::now()) {
178                Ok(()) => return,
179                Err(retry_after) => sleep(retry_after).await,
180            }
181        }
182    }
183
184    pub(crate) fn try_acquire(&self, kind: RateLimitKind) -> Result<(), Duration> {
185        self.limiter(kind)
186            .map_or(Ok(()), |limiter| limiter.try_acquire(Instant::now()))
187    }
188
189    pub(crate) fn cool_down(&self, kind: RateLimitKind, retry_after: Duration) {
190        if let Some(limiter) = self.limiter(kind) {
191            limiter.cool_down(Instant::now(), retry_after);
192        }
193    }
194
195    fn limiter(&self, kind: RateLimitKind) -> Option<&WindowLimiter> {
196        match kind {
197            RateLimitKind::History => self.history.as_ref(),
198            RateLimitKind::General => self.general.as_ref(),
199        }
200    }
201}
202
203struct WindowLimiter {
204    limit: RateLimit,
205    state: Mutex<WindowState>,
206}
207
208impl WindowLimiter {
209    fn new(limit: RateLimit) -> Self {
210        Self {
211            limit,
212            state: Mutex::new(WindowState::with_capacity(
213                limit.max_requests.min(MAX_INITIAL_CAPACITY),
214            )),
215        }
216    }
217
218    fn try_acquire(&self, now: Instant) -> Result<(), Duration> {
219        let mut state = self.state.lock();
220        state.expire(now, self.limit.window);
221        let retry_after = state.retry_after(now, self.limit);
222        if retry_after.is_zero() {
223            state.admitted.push_back(now);
224            Ok(())
225        } else {
226            Err(retry_after)
227        }
228    }
229
230    fn cool_down(&self, now: Instant, retry_after: Duration) {
231        let deadline = saturating_deadline(now, retry_after.min(MAX_COOLDOWN));
232        let mut state = self.state.lock();
233        if state
234            .cooldown_until
235            .is_none_or(|current| deadline > current)
236        {
237            state.cooldown_until = Some(deadline);
238        }
239    }
240}
241
242struct WindowState {
243    admitted: VecDeque<Instant>,
244    cooldown_until: Option<Instant>,
245}
246
247impl WindowState {
248    fn with_capacity(capacity: usize) -> Self {
249        Self {
250            admitted: VecDeque::with_capacity(capacity),
251            cooldown_until: None,
252        }
253    }
254
255    fn expire(&mut self, now: Instant, window: Duration) {
256        while self
257            .admitted
258            .front()
259            .is_some_and(|admitted| now.saturating_duration_since(*admitted) >= window)
260        {
261            self.admitted.pop_front();
262        }
263        if self.cooldown_until.is_some_and(|deadline| deadline <= now) {
264            self.cooldown_until = None;
265        }
266    }
267
268    fn retry_after(&self, now: Instant, limit: RateLimit) -> Duration {
269        let cooldown = self.cooldown_until.map_or(Duration::ZERO, |deadline| {
270            deadline.saturating_duration_since(now)
271        });
272        let capacity = if self.admitted.len() < limit.max_requests {
273            Duration::ZERO
274        } else {
275            self.admitted.front().map_or(limit.window, |oldest| {
276                oldest
277                    .checked_add(limit.window)
278                    .map_or(limit.window, |deadline| {
279                        deadline.saturating_duration_since(now)
280                    })
281            })
282        };
283        cooldown.max(capacity)
284    }
285}
286
287/// Returns `now + duration`, or the latest instant the monotonic clock can
288/// represent when the addition overflows, so a cooldown the clock cannot
289/// express still blocks admission to the end of the clock instead of being
290/// dropped.
291fn saturating_deadline(now: Instant, duration: Duration) -> Instant {
292    if let Some(deadline) = now.checked_add(duration) {
293        return deadline;
294    }
295    // The full duration overflows, so a representable offset exists below
296    // it. Binary-search nanosecond offsets capped at `u64::MAX`, the largest
297    // `Duration::from_nanos` can express. Every assignment to `low` follows
298    // a successful clock probe, so `deadline` is only ever set from a
299    // representable offset; the initial zero offset is representable by
300    // definition and can only survive the search when not even the next
301    // nanosecond is. That terminal case yields a no-op cooldown —
302    // `expire` clears it on the next admission attempt — because no future
303    // instant exists to block until.
304    let mut low = 0_u64;
305    let mut high = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
306    let mut deadline = now;
307    while high - low > 1 {
308        let mid = u64::midpoint(low, high);
309        if let Some(probe) = now.checked_add(Duration::from_nanos(mid)) {
310            low = mid;
311            deadline = probe;
312        } else {
313            high = mid;
314        }
315    }
316    deadline
317}
318
319#[cfg(test)]
320mod tests {
321    use std::sync::Arc;
322
323    use tokio::time::{Instant, advance};
324
325    use super::*;
326
327    fn limits(requests: usize, window: Duration) -> Arc<RateLimits> {
328        let limit = RateLimit::new(requests, window)
329            .unwrap_or_else(|error| panic!("test rate limit must be valid: {error}"));
330        Arc::new(RateLimits::new(Some(RateLimitConfig::new(limit, limit))))
331    }
332
333    #[tokio::test(start_paused = true)]
334    async fn wait_resumes_when_the_rolling_window_releases_capacity() {
335        let window = Duration::from_secs(10);
336        let limits = limits(1, window);
337        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
338
339        let started = Instant::now();
340        let waiting_limits = Arc::clone(&limits);
341        let waiter = tokio::spawn(async move {
342            waiting_limits.wait(RateLimitKind::General).await;
343            Instant::now()
344        });
345        tokio::task::yield_now().await;
346        assert!(!waiter.is_finished());
347
348        advance(window).await;
349        let admitted = waiter
350            .await
351            .unwrap_or_else(|error| panic!("waiting task must finish: {error}"));
352        assert_eq!(admitted.saturating_duration_since(started), window);
353    }
354
355    #[tokio::test(start_paused = true)]
356    async fn provider_cooldown_blocks_admission_until_its_deadline() {
357        let cooldown = Duration::from_secs(15);
358        let limits = limits(2, Duration::from_secs(30));
359        limits.cool_down(RateLimitKind::General, cooldown);
360
361        assert_eq!(limits.try_acquire(RateLimitKind::General), Err(cooldown));
362        advance(cooldown).await;
363        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
364    }
365
366    #[tokio::test(start_paused = true)]
367    async fn an_oversized_cooldown_is_clamped_not_skipped() {
368        let limits = limits(2, Duration::from_secs(30));
369        limits.cool_down(RateLimitKind::General, Duration::MAX);
370
371        assert_eq!(
372            limits.try_acquire(RateLimitKind::General),
373            Err(Duration::from_hours(24)),
374            "an unrepresentable provider cooldown must still block admission"
375        );
376    }
377
378    #[test]
379    fn an_unrepresentable_deadline_search_terminates_and_still_blocks() {
380        let now = Instant::now();
381        let deadline = saturating_deadline(now, Duration::MAX);
382        // The upper bound separates this from the fast path: a successful
383        // `checked_add(Duration::MAX)` would exceed the search's nanosecond
384        // cap, so passing both bounds proves the capped search ran.
385        let offset = deadline.saturating_duration_since(now);
386        assert!(
387            offset > Duration::from_hours(24) && offset <= Duration::from_nanos(u64::MAX),
388            "the saturated deadline must still block admission within the \
389             searched nanosecond domain, found {offset:?}"
390        );
391    }
392
393    #[tokio::test(start_paused = true)]
394    async fn rolling_window_expires_staggered_attempts_individually() {
395        let window = Duration::from_secs(10);
396        let limits = limits(2, window);
397        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
398        advance(Duration::from_secs(5)).await;
399        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
400
401        advance(Duration::from_secs(5)).await;
402        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
403        assert_eq!(
404            limits.try_acquire(RateLimitKind::General),
405            Err(Duration::from_secs(5))
406        );
407    }
408
409    #[tokio::test(start_paused = true)]
410    async fn cancelled_waiter_leaves_no_ghost_reservation() {
411        let window = Duration::from_secs(10);
412        let limits = limits(1, window);
413        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
414        let waiting_limits = Arc::clone(&limits);
415        let waiter = tokio::spawn(async move {
416            waiting_limits.wait(RateLimitKind::General).await;
417        });
418        tokio::task::yield_now().await;
419        waiter.abort();
420
421        advance(window).await;
422        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
423    }
424
425    #[tokio::test(start_paused = true)]
426    async fn default_history_budget_reaches_its_limit_before_throttling() {
427        let limits = Arc::new(RateLimits::new(Some(RateLimitConfig::default())));
428        for request_number in 1..=50 {
429            assert_eq!(
430                limits.try_acquire(RateLimitKind::History),
431                Ok(()),
432                "history request {request_number} must be admitted"
433            );
434        }
435        assert_eq!(
436            limits.try_acquire(RateLimitKind::History),
437            Err(Duration::from_secs(30))
438        );
439    }
440
441    #[tokio::test(start_paused = true)]
442    async fn default_general_budget_reaches_its_limit_before_throttling() {
443        let limits = Arc::new(RateLimits::new(Some(RateLimitConfig::default())));
444        for request_number in 1..=200 {
445            assert_eq!(
446                limits.try_acquire(RateLimitKind::General),
447                Ok(()),
448                "general request {request_number} must be admitted"
449            );
450        }
451        assert_eq!(
452            limits.try_acquire(RateLimitKind::General),
453            Err(Duration::from_mins(1))
454        );
455    }
456
457    #[tokio::test(start_paused = true)]
458    async fn later_provider_cooldown_cannot_be_shortened() {
459        let limits = limits(200, Duration::from_mins(1));
460        limits.cool_down(RateLimitKind::General, Duration::from_secs(30));
461        limits.cool_down(RateLimitKind::General, Duration::from_secs(5));
462        assert_eq!(
463            limits.try_acquire(RateLimitKind::General),
464            Err(Duration::from_secs(30))
465        );
466    }
467
468    #[tokio::test(start_paused = true)]
469    async fn admission_uses_the_later_of_window_and_provider_cooldown() {
470        let limits = limits(1, Duration::from_secs(20));
471        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
472        limits.cool_down(RateLimitKind::General, Duration::from_secs(5));
473        assert_eq!(
474            limits.try_acquire(RateLimitKind::General),
475            Err(Duration::from_secs(20))
476        );
477    }
478
479    #[test]
480    fn history_and_general_budgets_are_independent() {
481        let limits = limits(1, Duration::from_secs(30));
482        assert_eq!(limits.try_acquire(RateLimitKind::General), Ok(()));
483        assert_eq!(limits.try_acquire(RateLimitKind::History), Ok(()));
484        assert!(limits.try_acquire(RateLimitKind::General).is_err());
485        assert!(limits.try_acquire(RateLimitKind::History).is_err());
486    }
487
488    #[test]
489    fn public_defaults_match_the_provider_contract() {
490        let config = RateLimitConfig::default();
491        assert_eq!(config.history().max_requests(), 50);
492        assert_eq!(config.history().window(), Duration::from_secs(30));
493        assert_eq!(config.general().max_requests(), 200);
494        assert_eq!(config.general().window(), Duration::from_mins(1));
495    }
496
497    #[test]
498    fn public_rate_limit_rejects_zero_values() {
499        assert!(RateLimit::new(0, Duration::from_secs(1)).is_err());
500        assert!(RateLimit::new(1, Duration::ZERO).is_err());
501    }
502}