crawlkit_engine/
ratelimit.rs1use 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
10pub struct RateLimiter {
26 domain_buckets: DashMap<String, TokenBucket>,
28 global_bucket: Mutex<TokenBucket>,
30 per_domain_rps: f64,
32 global_rps: f64,
34 per_domain_burst: usize,
36 _global_burst: usize,
38 concurrency_limit: Option<Arc<Semaphore>>,
40}
41
42impl RateLimiter {
43 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 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 let global_rps = per_domain_rps * config.concurrency as f64;
68 Self::new(per_domain_rps, global_rps)
69 }
70
71 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 pub async fn acquire(&self, domain: &str) -> Result<(), RateLimitError> {
84 self.acquire_with_timeout(domain, Duration::from_secs(60))
85 .await
86 }
87
88 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 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 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 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 loop {
141 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 };
161
162 tokio::time::sleep(sleep_time).await;
163 }
164
165 Ok(())
166 }
167
168 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 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 pub fn global_tokens(&self) -> f64 {
193 self.global_bucket.lock().tokens()
194 }
195
196 pub fn per_domain_rps(&self) -> f64 {
198 self.per_domain_rps
199 }
200
201 pub fn global_rps(&self) -> f64 {
203 self.global_rps
204 }
205}
206
207#[derive(Debug, Clone, thiserror::Error)]
209pub enum RateLimitError {
210 #[error("rate limit acquire timed out")]
212 Timeout,
213 #[error("rate limiter closed")]
215 Closed,
216}
217
218#[derive(Debug)]
220struct TokenBucket {
221 tokens: f64,
223 max_tokens: f64,
225 refill_rate: f64,
227 last_refill: Instant,
229}
230
231impl TokenBucket {
232 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 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 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 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 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)); }
293
294 #[test]
295 fn test_token_bucket_refill() {
296 let mut bucket = TokenBucket::new(100.0, 10);
297 for _ in 0..10 {
299 bucket.try_consume(1);
300 }
301 assert!(!bucket.try_consume(1));
302
303 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 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 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 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 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 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 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); 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); let _ = limiter
389 .acquire_with_timeout("example.com", Duration::from_secs(1))
390 .await;
391 let result = limiter
393 .acquire_with_timeout("example.com", Duration::from_millis(50))
394 .await;
395 assert!(matches!(result, Err(RateLimitError::Timeout)));
396 }
397}