Skip to main content

tycho_util/
rate_limit.rs

1use std::hash::Hash;
2use std::num::NonZeroU32;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
5use std::time::Duration;
6
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9
10use crate::{FastDashMap, FastHashMap, serde_helpers, time};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TrafficLimit {
14    pub rate_per_sec: NonZeroU32,
15    pub burst: NonZeroU32,
16}
17
18impl TrafficLimit {
19    // Millisecond GCRA cannot represent intervals smaller than 1ms.
20    pub const MAX_RATE_PER_SEC: u32 = 1_000;
21
22    pub const fn new(rate_per_sec: NonZeroU32, burst: NonZeroU32) -> Self {
23        Self {
24            rate_per_sec,
25            burst,
26        }
27    }
28
29    fn normalize(&mut self) {
30        if self.rate_per_sec.get() > Self::MAX_RATE_PER_SEC {
31            self.rate_per_sec = NonZeroU32::new(Self::MAX_RATE_PER_SEC).unwrap();
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct RateLimitConfig {
39    pub rejects_before_cooldown: u8,
40    #[serde(with = "serde_helpers::humantime")]
41    pub cooldown: Duration,
42    #[serde(with = "serde_helpers::humantime")]
43    pub prune_interval: Duration,
44    #[serde(with = "serde_helpers::humantime")]
45    pub state_ttl: Duration,
46}
47
48impl RateLimitConfig {
49    pub const MIN_REJECTS_BEFORE_COOLDOWN: u8 = 1;
50    pub const MAX_REJECTS_BEFORE_COOLDOWN: u8 = u8::MAX - 1;
51    pub const MIN_STATE_TTL: Duration = Duration::from_secs(1);
52    pub const MIN_PRUNE_INTERVAL: Duration = Duration::from_secs(1);
53
54    fn normalize(&mut self) {
55        self.rejects_before_cooldown = self.rejects_before_cooldown.clamp(
56            Self::MIN_REJECTS_BEFORE_COOLDOWN,
57            Self::MAX_REJECTS_BEFORE_COOLDOWN,
58        );
59
60        if self.prune_interval.is_zero() {
61            self.prune_interval = Self::MIN_PRUNE_INTERVAL;
62        }
63
64        if self.state_ttl.is_zero() {
65            self.state_ttl = Self::MIN_STATE_TTL;
66        }
67    }
68}
69
70impl Default for RateLimitConfig {
71    fn default() -> Self {
72        Self {
73            rejects_before_cooldown: 5,
74            cooldown: Duration::from_secs(30),
75            prune_interval: Duration::from_secs(30),
76            state_ttl: Duration::from_secs(300),
77        }
78    }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub enum RateLimitVerdict {
83    Allow,
84    Reject { retry_after: Duration },
85}
86
87#[derive(Debug, Clone, Copy)]
88pub struct RateLimitPolicy<C> {
89    pub class: C,
90    pub limit: TrafficLimit,
91}
92
93#[derive(Clone)]
94pub struct RateLimiter<K, C> {
95    inner: Arc<RateLimiterInner<K, C>>,
96}
97
98struct RateLimiterInner<K, C> {
99    config: RateLimitConfig,
100    states: FastDashMap<K, Arc<PeerLimiter<C>>>,
101    last_prune_ms: AtomicU64,
102}
103
104impl<K, C> RateLimiter<K, C>
105where
106    K: Clone + Eq + Hash + Send + Sync + 'static,
107    C: Copy + Eq + Hash + Send + Sync + 'static,
108{
109    pub fn new(mut config: RateLimitConfig) -> Self {
110        config.normalize();
111
112        Self {
113            inner: Arc::new(RateLimiterInner {
114                config,
115                states: FastDashMap::default(),
116                last_prune_ms: AtomicU64::new(time::now_millis()),
117            }),
118        }
119    }
120
121    pub fn check(&self, key: &K, policy: RateLimitPolicy<C>) -> RateLimitVerdict {
122        self.inner.check(key, policy)
123    }
124}
125
126impl<K, C> RateLimiterInner<K, C>
127where
128    K: Clone + Eq + Hash + Send + Sync + 'static,
129    C: Copy + Eq + Hash + Send + Sync + 'static,
130{
131    fn check(&self, key: &K, policy: RateLimitPolicy<C>) -> RateLimitVerdict {
132        let now = time::now_millis();
133
134        self.maybe_prune(now);
135
136        let peer = self.peer(key, now);
137        peer.check(&self.config, policy, now)
138    }
139
140    fn peer(&self, key: &K, now: u64) -> Arc<PeerLimiter<C>> {
141        let state_ttl = self.config.state_ttl.as_millis_u64();
142
143        if let Some(peer) = self.states.get(key)
144            && !peer.is_expired(now, state_ttl)
145        {
146            return peer.clone();
147        }
148
149        let mut entry = self
150            .states
151            .entry(key.clone())
152            .or_insert_with(|| Arc::new(PeerLimiter::new(now)));
153
154        if entry.is_expired(now, state_ttl) {
155            *entry = Arc::new(PeerLimiter::new(now));
156        }
157
158        entry.clone()
159    }
160
161    fn prune_expired(&self, now: u64) {
162        let state_ttl = self.config.state_ttl.as_millis_u64();
163
164        self.states
165            .retain(|_, peer| peer.in_cooldown(now) || !peer.is_expired(now, state_ttl));
166    }
167
168    fn maybe_prune(&self, now: u64) {
169        let last = self.last_prune_ms.load(Ordering::Relaxed);
170
171        if now.saturating_sub(last) < self.config.prune_interval.as_millis_u64() {
172            return;
173        }
174
175        if self
176            .last_prune_ms
177            .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
178            .is_ok()
179        {
180            self.prune_expired(now);
181        }
182    }
183}
184
185struct PeerLimiter<C> {
186    buckets: RwLock<FastHashMap<C, Arc<AtomicBucket>>>,
187    rejects: AtomicU8,
188    cooldown_until_ms: AtomicU64,
189    last_seen_ms: AtomicU64,
190}
191
192impl<C> PeerLimiter<C>
193where
194    C: Copy + Eq + Hash + Send + Sync + 'static,
195{
196    fn new(now_ms: u64) -> Self {
197        Self {
198            buckets: RwLock::new(FastHashMap::default()),
199            rejects: AtomicU8::new(0),
200            cooldown_until_ms: AtomicU64::new(0),
201            last_seen_ms: AtomicU64::new(now_ms),
202        }
203    }
204
205    fn check(
206        &self,
207        config: &RateLimitConfig,
208        policy: RateLimitPolicy<C>,
209        now: u64,
210    ) -> RateLimitVerdict {
211        self.last_seen_ms.store(now, Ordering::Relaxed);
212
213        let cooldown_until = self.cooldown_until_ms.load(Ordering::Acquire);
214        if now < cooldown_until {
215            return RateLimitVerdict::Reject {
216                retry_after: Duration::from_millis(cooldown_until - now),
217            };
218        }
219
220        let bucket = self.bucket(policy.class, policy.limit);
221        let BucketResult::TryLater { after } = bucket.try_claim(now) else {
222            self.rejects.store(0, Ordering::Relaxed);
223            return RateLimitVerdict::Allow;
224        };
225
226        self.register_rejection(config, now);
227
228        // Prefer cooldown wait time.
229        let cooldown_until = self.cooldown_until_ms.load(Ordering::Acquire);
230        let retry_after = if now < cooldown_until {
231            cooldown_until - now
232        } else {
233            after.saturating_sub(now)
234        };
235
236        RateLimitVerdict::Reject {
237            retry_after: Duration::from_millis(retry_after),
238        }
239    }
240
241    fn bucket(&self, class: C, config: TrafficLimit) -> Arc<AtomicBucket> {
242        if let Some(bucket) = self.buckets.read().get(&class) {
243            return bucket.clone();
244        }
245
246        self.buckets
247            .write()
248            .entry(class)
249            .or_insert_with(|| Arc::new(AtomicBucket::new(config)))
250            .clone()
251    }
252
253    fn register_rejection(&self, config: &RateLimitConfig, now: u64) {
254        let prev_rejects = self.rejects.fetch_add(1, Ordering::AcqRel);
255        debug_assert!(prev_rejects < u8::MAX);
256
257        let rejects = prev_rejects.saturating_add(1);
258        if rejects >= config.rejects_before_cooldown {
259            self.rejects.store(0, Ordering::Release);
260            self.cooldown_until_ms.fetch_max(
261                now.saturating_add(config.cooldown.as_millis_u64()),
262                Ordering::AcqRel,
263            );
264        }
265    }
266
267    fn is_expired(&self, now: u64, state_ttl: u64) -> bool {
268        let last_seen = self.last_seen_ms.load(Ordering::Relaxed);
269        now.saturating_sub(last_seen) >= state_ttl
270    }
271
272    fn in_cooldown(&self, now: u64) -> bool {
273        now < self.cooldown_until_ms.load(Ordering::Acquire)
274    }
275}
276
277#[derive(Debug, PartialEq, Eq)]
278enum BucketResult {
279    Ok,
280    TryLater { after: u64 },
281}
282
283/// GCRA bucket
284struct AtomicBucket {
285    /// Theoretical arrival time
286    tat_ms: AtomicU64,
287    /// Spacing between requests for configured rate
288    interval_ms: u64,
289    /// Burst allowance.
290    tolerance_ms: u64,
291}
292
293impl AtomicBucket {
294    const MILLIS_PER_SEC: u64 = 1_000;
295
296    fn new(mut config: TrafficLimit) -> Self {
297        config.normalize();
298
299        let rate_per_sec = config.rate_per_sec.get() as u64;
300        let interval_ms = Self::MILLIS_PER_SEC.div_ceil(rate_per_sec).max(1);
301        let tolerance_ms = interval_ms.saturating_mul(config.burst.get().saturating_sub(1) as u64);
302
303        Self {
304            tat_ms: AtomicU64::new(0),
305            interval_ms,
306            tolerance_ms,
307        }
308    }
309
310    fn try_claim(&self, now: u64) -> BucketResult {
311        let mut tat = self.tat_ms.load(Ordering::Relaxed);
312
313        loop {
314            let allowed_at = tat.saturating_sub(self.tolerance_ms);
315            if now < allowed_at {
316                return BucketResult::TryLater { after: allowed_at };
317            }
318
319            let new_tat = tat.max(now).saturating_add(self.interval_ms);
320            match self.tat_ms.compare_exchange_weak(
321                tat,
322                new_tat,
323                Ordering::AcqRel,
324                Ordering::Relaxed,
325            ) {
326                Ok(_) => return BucketResult::Ok,
327                Err(next) => tat = next,
328            }
329        }
330    }
331}
332
333trait DurationExt {
334    fn as_millis_u64(&self) -> u64;
335}
336
337impl DurationExt for Duration {
338    fn as_millis_u64(&self) -> u64 {
339        self.as_millis().try_into().unwrap_or(u64::MAX)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[derive(Clone, Copy, PartialEq, Eq, Hash)]
348    enum Class {
349        A,
350        B,
351    }
352
353    fn bucket_config(rate_per_sec: u32, burst: u32) -> TrafficLimit {
354        TrafficLimit::new(
355            NonZeroU32::new(rate_per_sec).unwrap(),
356            NonZeroU32::new(burst).unwrap(),
357        )
358    }
359
360    fn policy(class: Class) -> RateLimitPolicy<Class> {
361        RateLimitPolicy {
362            class,
363            limit: bucket_config(1, 1),
364        }
365    }
366
367    fn rate_limiter() -> RateLimiter<u32, Class> {
368        RateLimiter::new(RateLimitConfig {
369            rejects_before_cooldown: 2,
370            ..Default::default()
371        })
372    }
373
374    #[test]
375    fn gcra_bucket_burst_and_refills() {
376        let now = time::now_millis();
377
378        let bucket = AtomicBucket::new(bucket_config(10, 2));
379
380        // 10 req/s = refill every 100ms
381        let delay = Duration::from_millis(100).as_millis_u64();
382
383        // Spend burst capacity
384        assert_eq!(bucket.try_claim(now), BucketResult::Ok);
385        assert_eq!(bucket.try_claim(now), BucketResult::Ok);
386
387        assert_eq!(bucket.try_claim(now), BucketResult::TryLater {
388            after: now + delay,
389        });
390
391        // Wait for refilling tokens
392        assert_eq!(bucket.try_claim(now + delay), BucketResult::Ok);
393    }
394
395    #[test]
396    fn rate_limiter_cooldown() {
397        let limiter = rate_limiter();
398
399        let key = 1;
400
401        // Spend burst capacity
402        assert_eq!(
403            limiter.check(&key, policy(Class::A)),
404            RateLimitVerdict::Allow
405        );
406
407        // Spend rejects limit
408        assert!(matches!(
409            limiter.check(&key, policy(Class::A)),
410            RateLimitVerdict::Reject { retry_after }
411                if retry_after <= Duration::from_secs(1)
412        ));
413
414        // Check cooldown
415        assert!(matches!(
416            limiter.check(&key, policy(Class::A)),
417            RateLimitVerdict::Reject { retry_after }
418                if retry_after > Duration::from_secs(29)
419        ));
420    }
421
422    #[test]
423    fn rate_limiter_keep_buckets_per_class() {
424        let limiter = rate_limiter();
425
426        let key = 1;
427
428        // Spend burst for A
429        assert_eq!(
430            limiter.check(&key, policy(Class::A)),
431            RateLimitVerdict::Allow
432        );
433
434        // Spend burst for B
435        assert_eq!(
436            limiter.check(&key, policy(Class::B)),
437            RateLimitVerdict::Allow
438        );
439
440        assert!(matches!(
441            limiter.check(&key, policy(Class::A)),
442            RateLimitVerdict::Reject { .. },
443        ));
444
445        assert!(matches!(
446            limiter.check(&key, policy(Class::B)),
447            RateLimitVerdict::Reject { .. },
448        ));
449    }
450}