Skip to main content

crawlkit_engine/
ratelimit.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use dashmap::DashMap;
5use parking_lot::Mutex;
6use tokio::sync::Semaphore;
7
8use crate::CrawlConfig;
9
10/// Token-bucket rate limiter for per-domain and global request throttling.
11///
12/// Each domain gets its own token bucket. A global bucket throttles
13/// all requests across all domains. A request proceeds only when both
14/// the domain and global buckets have tokens. Supports crawl-delay
15/// from robots.txt and optional concurrency limiting.
16///
17/// # Examples
18///
19/// ```rust
20/// use crawlkit_engine::ratelimit::RateLimiter;
21///
22/// let limiter = RateLimiter::new(2.0, 10.0);
23/// assert!((limiter.per_domain_rps() - 2.0).abs() < f64::EPSILON);
24/// ```
25pub struct RateLimiter {
26    /// Per-domain token buckets.
27    domain_buckets: DashMap<String, TokenBucket>,
28    /// Global token bucket.
29    global_bucket: Mutex<TokenBucket>,
30    /// Default per-domain RPS (requests per second).
31    per_domain_rps: f64,
32    /// Global RPS limit.
33    global_rps: f64,
34    /// Maximum burst size per domain.
35    per_domain_burst: usize,
36    /// Maximum burst size globally.
37    _global_burst: usize,
38    /// Optional semaphore for concurrency limiting.
39    concurrency_limit: Option<Arc<Semaphore>>,
40}
41
42impl RateLimiter {
43    /// Creates a new rate limiter with the given RPS settings.
44    pub fn new(per_domain_rps: f64, global_rps: f64) -> Self {
45        let per_domain_burst = (per_domain_rps * 2.0).ceil() as usize;
46        let global_burst = (global_rps * 2.0).ceil() as usize;
47
48        Self {
49            domain_buckets: DashMap::new(),
50            global_bucket: Mutex::new(TokenBucket::new(global_rps, global_burst)),
51            per_domain_rps,
52            global_rps,
53            per_domain_burst,
54            _global_burst: global_burst,
55            concurrency_limit: None,
56        }
57    }
58
59    /// Creates a rate limiter from a `CrawlConfig`.
60    pub fn from_crawl_config(config: &CrawlConfig) -> Self {
61        let per_domain_rps = if config.request_delay.is_zero() {
62            10.0
63        } else {
64            1.0 / config.request_delay.as_secs_f64()
65        };
66        // Global RPS = per-domain * concurrency as a rough default
67        let global_rps = per_domain_rps * config.concurrency as f64;
68        Self::new(per_domain_rps, global_rps)
69    }
70
71    /// Creates a rate limiter with a concurrency limit.
72    pub fn with_concurrency(self, max_concurrent: usize) -> Self {
73        Self {
74            concurrency_limit: Some(Arc::new(Semaphore::new(max_concurrent))),
75            ..self
76        }
77    }
78
79    /// Acquires permission to make a request to the given domain.
80    ///
81    /// Blocks until tokens are available in both the domain and global
82    /// buckets. Returns `Err` if the wait exceeds the timeout.
83    pub async fn acquire(&self, domain: &str) -> Result<(), RateLimitError> {
84        self.acquire_with_timeout(domain, Duration::from_secs(60))
85            .await
86    }
87
88    /// Acquires permission with a timeout.
89    pub async fn acquire_with_timeout(
90        &self,
91        domain: &str,
92        timeout: Duration,
93    ) -> Result<(), RateLimitError> {
94        let deadline = Instant::now() + timeout;
95
96        // Acquire concurrency permit if configured
97        let _concurrency_permit = if let Some(ref sem) = self.concurrency_limit {
98            let permit = Arc::clone(sem)
99                .acquire_owned()
100                .await
101                .map_err(|_| RateLimitError::Closed)?;
102            Some(permit)
103        } else {
104            None
105        };
106
107        // Wait for domain bucket token
108        loop {
109            {
110                let mut bucket = self
111                    .domain_buckets
112                    .entry(domain.to_string())
113                    .or_insert_with(|| {
114                        TokenBucket::new(self.per_domain_rps, self.per_domain_burst)
115                    });
116                bucket.refill();
117
118                if bucket.try_consume(1) {
119                    break;
120                }
121            }
122
123            if Instant::now() >= deadline {
124                return Err(RateLimitError::Timeout);
125            }
126
127            // Sleep for the time until the next token is available
128            let wait = self
129                .domain_buckets
130                .get(domain)
131                .map(|b| b.time_until_next_token())
132                .unwrap_or(Duration::from_millis(10));
133
134            let remaining = deadline.saturating_duration_since(Instant::now());
135            let sleep_time = wait.min(remaining);
136            tokio::time::sleep(sleep_time).await;
137        }
138
139        // Wait for global bucket token
140        loop {
141            // Acquire lock in a block to ensure it is dropped before any await point.
142            // `parking_lot::Mutex` guards are `!Send`; holding them across an `.await`
143            // would poison the future and violate tokio's Send requirement.
144            let sleep_time = {
145                let mut global = self.global_bucket.lock();
146                global.refill();
147
148                if global.try_consume(1) {
149                    break;
150                }
151
152                if Instant::now() >= deadline {
153                    return Err(RateLimitError::Timeout);
154                }
155
156                let wait = global.time_until_next_token();
157                let remaining = deadline.saturating_duration_since(Instant::now());
158                wait.min(remaining)
159                // Guard dropped here, before the await.
160            };
161
162            tokio::time::sleep(sleep_time).await;
163        }
164
165        Ok(())
166    }
167
168    /// Sets the crawl-delay for a specific domain (from robots.txt).
169    ///
170    /// This overrides the default RPS for that domain.
171    pub fn set_crawl_delay(&self, domain: &str, delay: Duration) {
172        let rps = if delay.is_zero() {
173            10.0
174        } else {
175            1.0 / delay.as_secs_f64()
176        };
177        let burst = (rps * 2.0).ceil() as usize;
178        self.domain_buckets
179            .insert(domain.to_string(), TokenBucket::new(rps, burst));
180    }
181
182    /// Returns the current token count for a domain bucket.
183    /// Creates the bucket if it doesn't exist.
184    pub fn domain_tokens(&self, domain: &str) -> f64 {
185        self.domain_buckets
186            .entry(domain.to_string())
187            .or_insert_with(|| TokenBucket::new(self.per_domain_rps, self.per_domain_burst))
188            .tokens()
189    }
190
191    /// Returns the current global token count.
192    pub fn global_tokens(&self) -> f64 {
193        self.global_bucket.lock().tokens()
194    }
195
196    /// Returns the per-domain RPS setting.
197    pub fn per_domain_rps(&self) -> f64 {
198        self.per_domain_rps
199    }
200
201    /// Returns the global RPS setting.
202    pub fn global_rps(&self) -> f64 {
203        self.global_rps
204    }
205}
206
207/// Errors that can occur during rate limiting.
208#[derive(Debug, Clone, thiserror::Error)]
209pub enum RateLimitError {
210    /// The wait timed out before a token became available.
211    #[error("rate limit acquire timed out")]
212    Timeout,
213    /// The rate limiter has been closed.
214    #[error("rate limiter closed")]
215    Closed,
216}
217
218/// Token bucket implementation for rate limiting.
219#[derive(Debug)]
220struct TokenBucket {
221    /// Current tokens available.
222    tokens: f64,
223    /// Maximum tokens (burst size).
224    max_tokens: f64,
225    /// Refill rate in tokens per second.
226    refill_rate: f64,
227    /// Last time tokens were refilled.
228    last_refill: Instant,
229}
230
231impl TokenBucket {
232    /// Creates a new token bucket.
233    fn new(rps: f64, burst: usize) -> Self {
234        Self {
235            tokens: burst as f64,
236            max_tokens: burst as f64,
237            refill_rate: rps,
238            last_refill: Instant::now(),
239        }
240    }
241
242    /// Refills tokens based on elapsed time since last refill.
243    fn refill(&mut self) {
244        let now = Instant::now();
245        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
246        self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
247        self.last_refill = now;
248    }
249
250    /// Tries to consume `n` tokens. Returns `true` if successful.
251    fn try_consume(&mut self, n: usize) -> bool {
252        if self.tokens >= n as f64 {
253            self.tokens -= n as f64;
254            true
255        } else {
256            false
257        }
258    }
259
260    /// Returns the time until the next token is available.
261    fn time_until_next_token(&self) -> Duration {
262        if self.tokens >= 1.0 {
263            Duration::ZERO
264        } else {
265            let deficit = 1.0 - self.tokens;
266            Duration::from_secs_f64(deficit / self.refill_rate)
267        }
268    }
269
270    /// Returns the current number of tokens.
271    fn tokens(&self) -> f64 {
272        self.tokens
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_token_bucket_basic() {
282        let mut bucket = TokenBucket::new(10.0, 10);
283        assert!(bucket.try_consume(1));
284        assert_eq!(bucket.tokens(), 9.0);
285    }
286
287    #[test]
288    fn test_token_bucket_empty() {
289        let mut bucket = TokenBucket::new(1.0, 1);
290        assert!(bucket.try_consume(1));
291        assert!(!bucket.try_consume(1)); // empty
292    }
293
294    #[test]
295    fn test_token_bucket_refill() {
296        let mut bucket = TokenBucket::new(100.0, 10);
297        // Drain
298        for _ in 0..10 {
299            bucket.try_consume(1);
300        }
301        assert!(!bucket.try_consume(1));
302
303        // Simulate time passing by manipulating last_refill
304        bucket.last_refill = Instant::now() - Duration::from_secs(1);
305        bucket.refill();
306        assert!(bucket.try_consume(1));
307    }
308
309    #[test]
310    fn test_token_bucket_burst() {
311        let mut bucket = TokenBucket::new(5.0, 10);
312        // Should allow burst of 10
313        for _ in 0..10 {
314            assert!(bucket.try_consume(1));
315        }
316        assert!(!bucket.try_consume(1));
317    }
318
319    #[test]
320    fn test_rate_limiter_creation() {
321        let limiter = RateLimiter::new(2.0, 10.0);
322        assert!((limiter.per_domain_rps() - 2.0).abs() < f64::EPSILON);
323        assert!((limiter.global_rps() - 10.0).abs() < f64::EPSILON);
324    }
325
326    #[test]
327    fn test_rate_limiter_from_crawl_config() {
328        let config = CrawlConfig::default();
329        let limiter = RateLimiter::from_crawl_config(&config);
330        // request_delay is 500ms → 2 RPS per domain
331        assert!((limiter.per_domain_rps() - 2.0).abs() < f64::EPSILON);
332    }
333
334    #[test]
335    fn test_rate_limiter_domain_tokens() {
336        let limiter = RateLimiter::new(5.0, 20.0);
337        let tokens = limiter.domain_tokens("example.com");
338        // Bucket is created lazily with burst = ceil(5.0 * 2.0) = 10
339        assert!(
340            (9.0..=11.0).contains(&tokens),
341            "expected ~10.0, got {tokens}"
342        );
343    }
344
345    #[test]
346    fn test_rate_limiter_global_tokens() {
347        let limiter = RateLimiter::new(5.0, 20.0);
348        let tokens = limiter.global_tokens();
349        // Should start at burst level (40)
350        assert!((tokens - 40.0).abs() < f64::EPSILON);
351    }
352
353    #[test]
354    fn test_set_crawl_delay() {
355        let limiter = RateLimiter::new(5.0, 20.0);
356        limiter.set_crawl_delay("example.com", Duration::from_secs(2));
357        // 0.5 RPS, burst = 1
358        let tokens = limiter.domain_tokens("example.com");
359        assert!((tokens - 1.0).abs() < f64::EPSILON);
360    }
361
362    #[test]
363    fn test_token_bucket_time_until_next_token() {
364        let bucket = TokenBucket::new(10.0, 5);
365        assert_eq!(bucket.time_until_next_token(), Duration::ZERO);
366
367        let mut bucket = TokenBucket::new(10.0, 1);
368        bucket.try_consume(1);
369        let wait = bucket.time_until_next_token();
370        // Should be ~100ms (1 token / 10 tokens per second)
371        assert!(wait > Duration::from_millis(90));
372        assert!(wait <= Duration::from_millis(110));
373    }
374
375    #[tokio::test]
376    async fn test_rate_limiter_acquire() {
377        let limiter = RateLimiter::new(100.0, 1000.0); // high RPS for fast test
378        let result = limiter
379            .acquire_with_timeout("example.com", Duration::from_secs(1))
380            .await;
381        assert!(result.is_ok());
382    }
383
384    #[tokio::test]
385    async fn test_rate_limiter_acquire_timeout() {
386        let limiter = RateLimiter::new(0.001, 0.001); // very low RPS
387                                                      // Consume the initial burst token
388        let _ = limiter
389            .acquire_with_timeout("example.com", Duration::from_secs(1))
390            .await;
391        // Now the bucket is empty, should time out
392        let result = limiter
393            .acquire_with_timeout("example.com", Duration::from_millis(50))
394            .await;
395        assert!(matches!(result, Err(RateLimitError::Timeout)));
396    }
397}