Skip to main content

recursive/http/
rate_limit.rs

1//! Token-bucket rate limiting and metrics middleware.
2
3use 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/// Token-bucket rate limiter keyed by client identifier (API key or remote IP).
15#[derive(Clone)]
16pub struct RateLimiter {
17    /// Tokens remaining per client key.
18    buckets: Arc<Mutex<HashMap<String, TokenBucket>>>,
19    /// Max tokens per bucket.
20    capacity: u32,
21    /// Tokens refilled per second.
22    refill_rate: f64,
23}
24
25/// A single token bucket for one client.
26struct TokenBucket {
27    tokens: f64,
28    last_refill: Instant,
29}
30
31impl RateLimiter {
32    /// Create a new rate limiter with the given capacity and refill rate.
33    ///
34    /// - `capacity`: maximum number of tokens (burst size).
35    /// - `refill_rate`: tokens added per second.
36    ///
37    /// External callers (tests, custom embedders) can construct a
38    /// `RateLimiter` directly and inject it via
39    /// [`build_router_with_auth_and_rate_limit`] when env-driven
40    /// configuration is undesirable.
41    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    /// Check if a request from `key` is allowed.
50    ///
51    /// Returns `true` if the request is within the rate limit, `false` if it
52    /// should be rejected (429).
53    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            // New client gets a full bucket
59            let tokens = self.capacity as f64;
60            TokenBucket {
61                tokens,
62                last_refill: now,
63            }
64        });
65
66        // Refill tokens based on elapsed time
67        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    /// Remove idle token buckets (tokens fully refilled to capacity).
81    ///
82    /// Safe to drop: a re-arriving client gets a fresh full bucket,
83    /// which is the same as the stored state.
84    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    /// Return the number of client buckets currently stored.
90    #[cfg(test)]
91    pub(super) async fn bucket_count(&self) -> usize {
92        self.buckets.lock().await.len()
93    }
94}
95
96/// Build a `RateLimiter` from environment variables.
97///
98/// - `RECURSIVE_RATE_LIMIT_RPM`: requests per minute (default: 60)
99/// - `RECURSIVE_RATE_LIMIT_BURST`: burst capacity (default: 10)
100pub 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    // Convert RPM to per-second refill rate
116    let refill_rate = rpm / 60.0;
117    RateLimiter::new(burst, refill_rate)
118}
119
120/// Hash a string value using `DefaultHasher` to avoid storing raw secrets.
121///
122/// Returns a stable hex string for the given input. Not cryptographically
123/// strong, but sufficient to prevent raw API key values from appearing in
124/// memory dumps or logs.
125fn hash_key(value: &str) -> String {
126    let mut h = DefaultHasher::new();
127    value.hash(&mut h);
128    format!("{:016x}", h.finish())
129}
130
131/// Extract a client key from the request for rate limiting.
132///
133/// Uses the `X-API-Key` header if present, hashing the value so the raw
134/// credential is never stored in the rate-limit bucket map (prevents leaking
135/// keys via memory dumps). Falls back to the **leftmost** entry of
136/// `X-Forwarded-For` when present (reverse-proxy deployments — the
137/// previous form fell back to the **proxy** IP via `ConnectInfo`,
138/// which is the same value for every client behind a load balancer;
139/// NEW-HTTP-7 fix), then to the socket IP, then to `ip:unknown`.
140///
141/// The leftmost XFF entry is the original client per the de-facto
142/// convention; **operators must configure their proxy to append
143/// the client IP** (nginx `proxy_set_header X-Forwarded-For
144/// $remote_addr;` does this by default). For a deployment
145/// without a trusted proxy, the XFF header can be **forged** by
146/// clients — in that case the proxy's own IP (the second XFF
147/// entry) is more honest. We do not currently detect the
148/// "no trusted proxy" case; documented as a follow-up.
149pub(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    // XFF first — leftmost is the original client per de-facto
156    // convention. Trims whitespace; falls through if header is
157    // present but empty.
158    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    // Fall back to socket IP (the proxy's own IP behind a load
169    // balancer, but still better than `ip:unknown` for direct
170    // connections).
171    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
177/// Middleware that increments request counters.
178pub(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
190/// Middleware that enforces rate limits on all API requests.
191pub(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    /// Helper: create a rate limiter with very small capacity for testing.
214    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); // 5 burst, 60 RPM
221        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); // 3 burst, 60 RPM
229        for _ in 0..3 {
230            assert!(limiter.check("client-b").await, "request should be allowed");
231        }
232        // Fourth request should be denied
233        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); // 2 burst, 60 RPM = 1 per second
242                                             // Exhaust the bucket
243        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        // Wait for refill (1 token per second, wait 1.1s to be safe)
248        tokio::time::sleep(Duration::from_millis(1100)).await;
249
250        // Should have at least 1 token now
251        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); // 2 burst
260
261        // Exhaust client-d
262        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        // client-e should still have a full bucket
270        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        // Key is prefixed with "apikey:" and the raw credential is not stored.
288        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        // Stable: same input produces the same bucket key.
297        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        // Different keys produce different bucket keys.
307        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        // No ConnectInfo extension, so falls back to "ip:unknown"
325        assert_eq!(key, "ip:unknown");
326    }
327
328    #[tokio::test]
329    async fn test_rate_limiter_from_env_defaults() {
330        // Unset the env vars to test defaults
331        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        // Default: 60 RPM, 10 burst
335        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        // Create entries and drain them partially
345        limiter.check("client-a").await;
346        limiter.check("client-b").await;
347        limiter.check("client-new-full").await;
348        // At this point all entries are partially drained (1 token left each).
349        // prune() should remove nothing.
350        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        // Insert a full bucket (tokens == capacity) to test eviction
358        {
359            let mut b = limiter.buckets.lock().await;
360            b.insert(
361                "idle-client".to_string(),
362                TokenBucket {
363                    tokens: 2.0, // == capacity → idle
364                    last_refill: std::time::Instant::now(),
365                },
366            );
367        }
368        limiter.prune().await;
369        // idle-client evicted, 3 partial buckets remain
370        assert_eq!(
371            limiter.bucket_count().await,
372            3,
373            "idle-client should be evicted"
374        );
375
376        // Directly refill the remaining buckets to capacity so
377        // prune() evicts them.
378        {
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    /// Goal-292: rate_limits_rejected counter increments when rate-limit fires.
395    #[tokio::test]
396    async fn rate_limits_rejected_counter_increments() {
397        let metrics = Arc::new(Metrics::default());
398        // Capacity 0 means every request is rejected immediately.
399        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        // First request — should get 429 and increment counter.
409        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        // Second request — counter increments to 2.
427        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        // NEW-HTTP-7: reverse-proxy deployments. The
465        // leftmost XFF entry is the original client per the
466        // de-facto convention. The previous form fell back
467        // to the proxy's socket IP, which is the same value
468        // for every client behind a load balancer — every
469        // request shared one rate-limit bucket, so the
470        // rate-limit effectively applied to the LB, not the
471        // client. With XFF support each client gets its own
472        // bucket.
473        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        // An XFF header that is present but empty is treated as
488        // absent — fall through to the socket-IP branch.
489        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}