1use axum::http::StatusCode;
4use std::collections::hash_map::DefaultHasher;
5use std::collections::HashMap;
6use std::hash::{Hash, Hasher};
7use std::sync::atomic::Ordering;
8use std::sync::Arc;
9use std::time::Instant;
10use tokio::sync::Mutex;
11
12use super::Metrics;
13
14#[derive(Clone)]
16pub struct RateLimiter {
17 buckets: Arc<Mutex<HashMap<String, TokenBucket>>>,
19 capacity: u32,
21 refill_rate: f64,
23}
24
25struct TokenBucket {
27 tokens: f64,
28 last_refill: Instant,
29}
30
31impl RateLimiter {
32 pub fn new(capacity: u32, refill_rate: f64) -> Self {
42 Self {
43 buckets: Arc::new(Mutex::new(HashMap::new())),
44 capacity,
45 refill_rate,
46 }
47 }
48
49 async fn check(&self, key: &str) -> bool {
54 let mut buckets = self.buckets.lock().await;
55 let now = Instant::now();
56
57 let bucket = buckets.entry(key.to_string()).or_insert_with(|| {
58 let tokens = self.capacity as f64;
60 TokenBucket {
61 tokens,
62 last_refill: now,
63 }
64 });
65
66 let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
68 let refill = elapsed * self.refill_rate;
69 bucket.tokens = (bucket.tokens + refill).min(self.capacity as f64);
70 bucket.last_refill = now;
71
72 if bucket.tokens >= 1.0 {
73 bucket.tokens -= 1.0;
74 true
75 } else {
76 false
77 }
78 }
79
80 pub(super) async fn prune(&self) {
85 let mut buckets = self.buckets.lock().await;
86 buckets.retain(|_, b| b.tokens < self.capacity as f64);
87 }
88
89 #[cfg(test)]
91 pub(super) async fn bucket_count(&self) -> usize {
92 self.buckets.lock().await.len()
93 }
94}
95
96pub fn rate_limiter_from_env() -> RateLimiter {
101 let rpm = std::env::var("RECURSIVE_RATE_LIMIT_RPM")
102 .ok()
103 .and_then(|v| v.parse::<f64>().ok())
104 .unwrap_or(60.0);
105 let burst = std::env::var("RECURSIVE_RATE_LIMIT_BURST")
106 .ok()
107 .and_then(|v| v.parse::<u32>().ok())
108 .unwrap_or(10);
109 if rpm <= 0.0 {
110 tracing::warn!("RECURSIVE_RATE_LIMIT_RPM=0: token bucket never refills, clients will be permanently blocked after burst");
111 }
112 if burst == 0 {
113 tracing::warn!("RECURSIVE_RATE_LIMIT_BURST=0: all requests will be rejected immediately");
114 }
115 let refill_rate = rpm / 60.0;
117 RateLimiter::new(burst, refill_rate)
118}
119
120fn hash_key(value: &str) -> String {
126 let mut h = DefaultHasher::new();
127 value.hash(&mut h);
128 format!("{:016x}", h.finish())
129}
130
131pub(super) fn extract_client_key(req: &axum::extract::Request) -> String {
150 if let Some(api_key) = req.headers().get("x-api-key") {
151 if let Ok(key) = api_key.to_str() {
152 return format!("apikey:{}", hash_key(key));
153 }
154 }
155 if let Some(xff) = req.headers().get("x-forwarded-for") {
159 if let Ok(s) = xff.to_str() {
160 if let Some(first) = s.split(',').next() {
161 let first = first.trim();
162 if !first.is_empty() {
163 return format!("xff:{first}");
164 }
165 }
166 }
167 }
168 req.extensions()
172 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
173 .map(|info| format!("ip:{}", info.ip()))
174 .unwrap_or_else(|| "ip:unknown".to_string())
175}
176
177pub(super) async fn metrics_middleware(
179 axum::extract::State(metrics): axum::extract::State<Arc<Metrics>>,
180 req: axum::extract::Request,
181 next: axum::middleware::Next,
182) -> axum::response::Response {
183 metrics.requests_total.fetch_add(1, Ordering::Relaxed);
184 metrics.requests_active.fetch_add(1, Ordering::Relaxed);
185 let response = next.run(req).await;
186 metrics.requests_active.fetch_sub(1, Ordering::Relaxed);
187 response
188}
189
190pub(super) async fn rate_limit_middleware(
192 axum::extract::State((limiter, metrics)): axum::extract::State<(RateLimiter, Arc<Metrics>)>,
193 req: axum::extract::Request,
194 next: axum::middleware::Next,
195) -> axum::response::Response {
196 let key = extract_client_key(&req);
197 if !limiter.check(&key).await {
198 metrics.rate_limits_rejected.fetch_add(1, Ordering::Relaxed);
199 let mut resp = axum::response::Response::new(axum::body::Body::from("rate limit exceeded"));
200 *resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
201 return resp;
202 }
203 next.run(req).await
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use axum::routing::get;
210 use std::time::Duration;
211 use tower::ServiceExt;
212
213 fn test_limiter(capacity: u32, rpm: f64) -> RateLimiter {
215 RateLimiter::new(capacity, rpm / 60.0)
216 }
217
218 #[tokio::test]
219 async fn test_requests_within_limit_succeed() {
220 let limiter = test_limiter(5, 60.0); for _ in 0..5 {
222 assert!(limiter.check("client-a").await, "request should be allowed");
223 }
224 }
225
226 #[tokio::test]
227 async fn test_requests_exceeding_limit_get_429() {
228 let limiter = test_limiter(3, 60.0); for _ in 0..3 {
230 assert!(limiter.check("client-b").await, "request should be allowed");
231 }
232 assert!(
234 !limiter.check("client-b").await,
235 "request should be rate limited"
236 );
237 }
238
239 #[tokio::test]
240 async fn test_tokens_refill_after_waiting() {
241 let limiter = test_limiter(2, 60.0); assert!(limiter.check("client-c").await);
244 assert!(limiter.check("client-c").await);
245 assert!(!limiter.check("client-c").await, "should be denied");
246
247 tokio::time::sleep(Duration::from_millis(1100)).await;
249
250 assert!(
252 limiter.check("client-c").await,
253 "should be allowed after refill"
254 );
255 }
256
257 #[tokio::test]
258 async fn test_different_clients_have_independent_buckets() {
259 let limiter = test_limiter(2, 60.0); assert!(limiter.check("client-d").await);
263 assert!(limiter.check("client-d").await);
264 assert!(
265 !limiter.check("client-d").await,
266 "client-d should be denied"
267 );
268
269 assert!(
271 limiter.check("client-e").await,
272 "client-e should be allowed"
273 );
274 assert!(
275 limiter.check("client-e").await,
276 "client-e should be allowed"
277 );
278 }
279
280 #[tokio::test]
281 async fn test_extract_client_key_with_api_key() {
282 let req = axum::http::Request::builder()
283 .header("x-api-key", "test-key-123")
284 .body(axum::body::Body::empty())
285 .unwrap();
286 let key = extract_client_key(&req);
287 assert!(
289 key.starts_with("apikey:"),
290 "expected apikey: prefix, got: {key}"
291 );
292 assert!(
293 !key.contains("test-key-123"),
294 "raw API key must not appear in bucket key"
295 );
296 let req2 = axum::http::Request::builder()
298 .header("x-api-key", "test-key-123")
299 .body(axum::body::Body::empty())
300 .unwrap();
301 assert_eq!(
302 key,
303 extract_client_key(&req2),
304 "bucket key must be deterministic"
305 );
306 let req3 = axum::http::Request::builder()
308 .header("x-api-key", "other-key-456")
309 .body(axum::body::Body::empty())
310 .unwrap();
311 assert_ne!(
312 key,
313 extract_client_key(&req3),
314 "different keys must have different buckets"
315 );
316 }
317
318 #[tokio::test]
319 async fn test_extract_client_key_without_api_key() {
320 let req = axum::http::Request::builder()
321 .body(axum::body::Body::empty())
322 .unwrap();
323 let key = extract_client_key(&req);
324 assert_eq!(key, "ip:unknown");
326 }
327
328 #[tokio::test]
329 async fn test_rate_limiter_from_env_defaults() {
330 std::env::remove_var("RECURSIVE_RATE_LIMIT_RPM");
332 std::env::remove_var("RECURSIVE_RATE_LIMIT_BURST");
333 let limiter = rate_limiter_from_env();
334 for _ in 0..10 {
336 assert!(limiter.check("default-client").await);
337 }
338 assert!(!limiter.check("default-client").await, "burst exceeded");
339 }
340
341 #[tokio::test]
342 async fn prune_removes_full_buckets() {
343 let limiter = RateLimiter::new(2, 1.0);
344 limiter.check("client-a").await;
346 limiter.check("client-b").await;
347 limiter.check("client-new-full").await;
348 limiter.prune().await;
351 let count_after = limiter.bucket_count().await;
352 assert_eq!(
353 count_after, 3,
354 "all partially drained, none should be removed"
355 );
356
357 {
359 let mut b = limiter.buckets.lock().await;
360 b.insert(
361 "idle-client".to_string(),
362 TokenBucket {
363 tokens: 2.0, last_refill: std::time::Instant::now(),
365 },
366 );
367 }
368 limiter.prune().await;
369 assert_eq!(
371 limiter.bucket_count().await,
372 3,
373 "idle-client should be evicted"
374 );
375
376 {
379 let mut b = limiter.buckets.lock().await;
380 let now = std::time::Instant::now();
381 for bucket in b.values_mut() {
382 bucket.tokens = 2.0;
383 bucket.last_refill = now;
384 }
385 }
386 limiter.prune().await;
387 assert_eq!(
388 limiter.bucket_count().await,
389 0,
390 "all buckets should be idle after manual refill"
391 );
392 }
393
394 #[tokio::test]
396 async fn rate_limits_rejected_counter_increments() {
397 let metrics = Arc::new(Metrics::default());
398 let limiter = RateLimiter::new(0, 0.0);
400
401 let app = axum::Router::new()
402 .route("/", get(|| async { "ok" }))
403 .layer(axum::middleware::from_fn_with_state(
404 (limiter, metrics.clone()),
405 rate_limit_middleware,
406 ));
407
408 let resp = app
410 .clone()
411 .oneshot(
412 axum::extract::Request::builder()
413 .uri("/")
414 .body(axum::body::Body::empty())
415 .unwrap(),
416 )
417 .await
418 .unwrap();
419 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
420 assert_eq!(
421 metrics.rate_limits_rejected.load(Ordering::Relaxed),
422 1,
423 "rate_limits_rejected should increment to 1"
424 );
425
426 let resp = app
428 .oneshot(
429 axum::extract::Request::builder()
430 .uri("/")
431 .body(axum::body::Body::empty())
432 .unwrap(),
433 )
434 .await
435 .unwrap();
436 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
437 assert_eq!(
438 metrics.rate_limits_rejected.load(Ordering::Relaxed),
439 2,
440 "rate_limits_rejected should increment to 2"
441 );
442 }
443}
444
445#[cfg(test)]
446mod goal_h3_xff {
447 use super::*;
448 use axum::extract::Request;
449
450 fn build_request_with_xff(xff: Option<&str>) -> Request {
451 let mut req = Request::builder()
452 .uri("/")
453 .body(axum::body::Body::empty())
454 .unwrap();
455 if let Some(xff) = xff {
456 req.headers_mut()
457 .insert("x-forwarded-for", xff.parse().expect("valid header value"));
458 }
459 req
460 }
461
462 #[test]
463 fn extract_client_key_uses_xff_when_no_apikey() {
464 let req = build_request_with_xff(Some("203.0.113.42, 10.0.0.1"));
474 let key = extract_client_key(&req);
475 assert_eq!(key, "xff:203.0.113.42");
476 }
477
478 #[test]
479 fn extract_client_key_trims_whitespace_in_xff() {
480 let req = build_request_with_xff(Some(" 203.0.113.42 , 10.0.0.1 "));
481 let key = extract_client_key(&req);
482 assert_eq!(key, "xff:203.0.113.42");
483 }
484
485 #[test]
486 fn extract_client_key_falls_back_when_xff_empty() {
487 let req = build_request_with_xff(Some(""));
490 let key = extract_client_key(&req);
491 assert!(
492 key.starts_with("ip:"),
493 "empty XFF should fall through to socket IP, got {key}"
494 );
495 }
496}