Skip to main content

oxihuman_core/
rate_limiter_sliding.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Sliding window rate limiter — tracks request timestamps in a ring buffer.
6
7/// Configuration for the sliding window rate limiter.
8#[derive(Clone, Debug)]
9pub struct SlidingRateLimiterConfig {
10    /// Maximum number of requests allowed in the window.
11    pub max_requests: usize,
12    /// Window size in milliseconds.
13    pub window_ms: u64,
14}
15
16impl Default for SlidingRateLimiterConfig {
17    fn default() -> Self {
18        Self {
19            max_requests: 100,
20            window_ms: 1000,
21        }
22    }
23}
24
25/// A sliding window rate limiter.
26pub struct SlidingRateLimiter {
27    pub config: SlidingRateLimiterConfig,
28    timestamps: Vec<u64>,
29}
30
31/// Creates a new sliding window rate limiter.
32pub fn new_rate_limiter(config: SlidingRateLimiterConfig) -> SlidingRateLimiter {
33    SlidingRateLimiter {
34        config,
35        timestamps: Vec::new(),
36    }
37}
38
39/// Returns true if the request is allowed, recording the timestamp.
40pub fn check_and_record(limiter: &mut SlidingRateLimiter, now_ms: u64) -> bool {
41    evict_old(limiter, now_ms);
42    if limiter.timestamps.len() < limiter.config.max_requests {
43        limiter.timestamps.push(now_ms);
44        true
45    } else {
46        false
47    }
48}
49
50/// Removes timestamps outside the current window.
51pub fn evict_old(limiter: &mut SlidingRateLimiter, now_ms: u64) {
52    let cutoff = now_ms.saturating_sub(limiter.config.window_ms);
53    limiter.timestamps.retain(|&t| t >= cutoff);
54}
55
56/// Returns the number of requests recorded in the current window.
57pub fn requests_in_window(limiter: &SlidingRateLimiter, now_ms: u64) -> usize {
58    let cutoff = now_ms.saturating_sub(limiter.config.window_ms);
59    limiter.timestamps.iter().filter(|&&t| t >= cutoff).count()
60}
61
62/// Returns the remaining request budget for the current window.
63pub fn remaining_budget(limiter: &SlidingRateLimiter, now_ms: u64) -> usize {
64    let used = requests_in_window(limiter, now_ms);
65    limiter.config.max_requests.saturating_sub(used)
66}
67
68/// Resets the limiter, clearing all recorded timestamps.
69pub fn reset_limiter(limiter: &mut SlidingRateLimiter) {
70    limiter.timestamps.clear();
71}
72
73impl SlidingRateLimiter {
74    /// Creates a new rate limiter with default config.
75    pub fn new(config: SlidingRateLimiterConfig) -> Self {
76        new_rate_limiter(config)
77    }
78
79    /// Returns true if request at `now_ms` is within the rate limit.
80    pub fn allow(&mut self, now_ms: u64) -> bool {
81        check_and_record(self, now_ms)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn make_limiter(max: usize, window_ms: u64) -> SlidingRateLimiter {
90        new_rate_limiter(SlidingRateLimiterConfig {
91            max_requests: max,
92            window_ms,
93        })
94    }
95
96    #[test]
97    fn test_allows_up_to_limit() {
98        let mut lim = make_limiter(3, 1000);
99        assert!(check_and_record(&mut lim, 0));
100        assert!(check_and_record(&mut lim, 100));
101        assert!(check_and_record(&mut lim, 200));
102        /* fourth request should be denied */
103        assert!(!check_and_record(&mut lim, 300));
104    }
105
106    #[test]
107    fn test_window_slides_allowing_new_requests() {
108        let mut lim = make_limiter(2, 1000);
109        check_and_record(&mut lim, 0);
110        check_and_record(&mut lim, 100);
111        /* slide window past first two requests */
112        assert!(check_and_record(&mut lim, 1100));
113    }
114
115    #[test]
116    fn test_evict_old_removes_entries() {
117        let mut lim = make_limiter(10, 1000);
118        check_and_record(&mut lim, 0);
119        evict_old(&mut lim, 2000);
120        assert_eq!(requests_in_window(&lim, 2000), 0);
121    }
122
123    #[test]
124    fn test_remaining_budget_decreases() {
125        let mut lim = make_limiter(5, 1000);
126        assert_eq!(remaining_budget(&lim, 0), 5);
127        check_and_record(&mut lim, 0);
128        assert_eq!(remaining_budget(&lim, 0), 4);
129    }
130
131    #[test]
132    fn test_reset_clears_timestamps() {
133        let mut lim = make_limiter(3, 1000);
134        check_and_record(&mut lim, 0);
135        check_and_record(&mut lim, 50);
136        reset_limiter(&mut lim);
137        assert_eq!(requests_in_window(&lim, 100), 0);
138    }
139
140    #[test]
141    fn test_requests_in_window_counts_correctly() {
142        let mut lim = make_limiter(10, 500);
143        check_and_record(&mut lim, 0);
144        check_and_record(&mut lim, 100);
145        /* t=600: inside window at now=700 (cutoff=200, 600>=200).
146         * t=0 is evicted when t=600 is recorded (evict_old cutoff=100, 0<100).
147         * t=100 is outside window at now=700 (100<200). Only t=600 counts. */
148        check_and_record(&mut lim, 600);
149        assert_eq!(requests_in_window(&lim, 700), 1);
150    }
151
152    #[test]
153    fn test_allow_method_delegates() {
154        let mut lim = SlidingRateLimiter::new(SlidingRateLimiterConfig {
155            max_requests: 2,
156            window_ms: 500,
157        });
158        assert!(lim.allow(0));
159        assert!(lim.allow(100));
160        assert!(!lim.allow(200));
161    }
162
163    #[test]
164    fn test_saturating_sub_no_underflow() {
165        /* window_ms larger than now_ms => cutoff should be 0 not wraparound */
166        let mut lim = make_limiter(5, 5000);
167        check_and_record(&mut lim, 10);
168        assert_eq!(requests_in_window(&lim, 50), 1);
169    }
170
171    #[test]
172    fn test_zero_limit_denies_all() {
173        let mut lim = make_limiter(0, 1000);
174        assert!(!check_and_record(&mut lim, 100));
175    }
176}