rate_limit_service.rs 496 lignes · 16547 octets
//! Generic in-process rate limiter.
//!
//! Tracks request counts per arbitrary key over a sliding window. Used
//! internally by authentication, public share, and password flow rate
//! limiters, and exposed publicly for apps that need to rate-limit their
//! own endpoints (imports, webhooks, etc.).
//!
//! # Example
//!
//! ```no_run
//! use std::sync::Arc;
//! use rustwarden_core::services::rate_limit_service::RateLimitService;
//!
//! let limiter = Arc::new(RateLimitService::new());
//! // Reject after 10 calls / minute per IP:
//! limiter.check(&format!("import:{}", "1.2.3.4"), 10).unwrap();
//! ```
//!
//! # Limitations
//!
//! - In-process only. Multi-instance deployments need a shared backend
//!   (Redis) — out of scope for v1.
//! - Memory-bounded via `MAX_ENTRIES`. When the map is at cap and the
//!   incoming key is unknown, the least-recently-used key (oldest
//!   most-recent timestamp) is evicted so a legitimate new caller is still
//!   admitted. Unknown keys are never rejected solely because the map is
//!   full.

use std::{
    collections::HashMap,
    sync::{Arc, Mutex},
    time::{Duration, Instant},
};

use crate::error::AppError;

/// Maximum distinct keys tracked before LRU-evicting the coldest entry (SEC-M2: memory DoS).
const MAX_ENTRIES: usize = 100_000;

/// Per-key counter plus the instant at which the current window opened.
#[derive(Debug, Clone, Copy)]
struct WindowCounter {
    count: u32,
    window_start: Instant,
}

/// Thread-safe rate limiter keyed by arbitrary strings.
///
/// Supports two semantics:
///
/// - [`check`]/[`check_window`]: fixed-window counter (resets at each window
///   boundary). Cheap, single integer per key.
/// - [`check_sliding_window`]: true sliding window (stores each attempt
///   timestamp). Stricter across boundaries; preferred for security-sensitive
///   flows like password reset throttling.
///
/// Uses a single `Mutex<HashMap>`; contention is acceptable because each
/// check is a handful of microseconds. A sharded `DashMap` backend can be
/// swapped in later without API change.
///
/// [`check`]: Self::check
/// [`check_window`]: Self::check_window
/// [`check_sliding_window`]: Self::check_sliding_window
#[derive(Clone, Default)]
pub struct RateLimitService {
    buckets: Arc<Mutex<HashMap<String, WindowCounter>>>,
    sliding: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
}

impl RateLimitService {
    /// Create a new `RateLimitService` with empty tracking maps.
    pub fn new() -> Self {
        Self::default()
    }

    /// Check and increment the counter for `key`. Returns `Err(TooManyRequests)`
    /// with a `Retry-After` hint when `max_per_minute` is exceeded inside the
    /// rolling 60-second window.
    pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError> {
        self.check_window(key, max_per_minute, Duration::from_secs(60))
    }

    /// Check and increment the counter for `key` with a custom window size.
    pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> {
        self.check_window_capped(key, max, window, MAX_ENTRIES)
    }

    fn check_window_capped(
        &self,
        key: &str,
        max: u32,
        window: Duration,
        cap: usize,
    ) -> Result<(), AppError> {
        let now = Instant::now();
        let mut buckets = self
            .buckets
            .lock()
            .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;

        // Drop expired entries so the map stays bounded on low-traffic keys.
        buckets.retain(|_, w| now.duration_since(w.window_start) < window);

        if !buckets.contains_key(key) {
            if buckets.len() >= cap {
                tracing::warn!(
                    entries = buckets.len(),
                    cap,
                    "rate limiter map full, evicting oldest window"
                );
            }
            evict_fixed_if_full(&mut buckets, cap);
        }

        let w = buckets.entry(key.to_string()).or_insert(WindowCounter {
            count: 0,
            window_start: now,
        });

        if now.duration_since(w.window_start) >= window {
            w.count = 0;
            w.window_start = now;
        }

        w.count += 1;
        if w.count > max {
            let elapsed = now.duration_since(w.window_start);
            let remaining = window.saturating_sub(elapsed).as_secs().max(1);
            tracing::warn!(
                key = %key,
                count = w.count,
                max,
                retry_after_secs = remaining,
                "rate limit exceeded"
            );
            return Err(AppError::TooManyRequests {
                retry_after_secs: remaining,
            });
        }

        Ok(())
    }

    /// True sliding-window check: rejects if more than `max` attempts occurred
    /// in the last `window` duration, counted attempt-by-attempt.
    ///
    /// Stricter than [`check_window`] because it never allows a burst across a
    /// window boundary. Use for security-sensitive throttling (password reset,
    /// email verification resend, etc.).
    ///
    /// [`check_window`]: Self::check_window
    pub fn check_sliding_window(
        &self,
        key: &str,
        max: u32,
        window: Duration,
    ) -> Result<(), AppError> {
        self.check_sliding_window_capped(key, max, window, MAX_ENTRIES)
    }

    fn check_sliding_window_capped(
        &self,
        key: &str,
        max: u32,
        window: Duration,
        cap: usize,
    ) -> Result<(), AppError> {
        let now = Instant::now();
        let mut sliding = self
            .sliding
            .lock()
            .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;

        if !sliding.contains_key(key) {
            if sliding.len() >= cap {
                tracing::warn!(
                    entries = sliding.len(),
                    cap,
                    "sliding rate limiter map full, evicting least-recently-used key"
                );
            }
            evict_if_full(&mut sliding, cap, now);
        }

        let attempts = sliding.entry(key.to_string()).or_default();
        attempts.retain(|t| now.duration_since(*t) < window);

        if attempts.len() as u32 >= max {
            let oldest = attempts.first().copied().unwrap_or(now);
            let elapsed = now.duration_since(oldest);
            let retry = window.saturating_sub(elapsed).as_secs().max(1);
            tracing::warn!(
                key = %key,
                attempts = attempts.len(),
                max,
                retry_after_secs = retry,
                "sliding rate limit exceeded"
            );
            return Err(AppError::TooManyRequests {
                retry_after_secs: retry,
            });
        }

        attempts.push(now);
        Ok(())
    }

    /// Background task that periodically drops expired buckets.
    ///
    /// Not strictly required (every `check` already evicts stale entries for
    /// its own window), but useful when most keys stop being accessed yet the
    /// map should not keep growing through bursty traffic.
    pub async fn cleanup_loop(self, interval: Duration, window: Duration) {
        let mut ticker = tokio::time::interval(interval);
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        loop {
            ticker.tick().await;
            let now = Instant::now();
            if let Ok(mut buckets) = self.buckets.lock() {
                buckets.retain(|_, w| now.duration_since(w.window_start) < window);
            }
            if let Ok(mut sliding) = self.sliding.lock() {
                sliding.retain(|_, attempts| {
                    attempts.retain(|t| now.duration_since(*t) < window);
                    !attempts.is_empty()
                });
            }
        }
    }

    /// Number of distinct keys currently tracked in the fixed-window map.
    pub fn len(&self) -> usize {
        self.buckets.lock().map(|b| b.len()).unwrap_or(0)
    }

    /// Return `true` if no keys are currently tracked in the fixed-window map.
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Number of distinct keys currently tracked in the sliding-window map.
    pub fn sliding_len(&self) -> usize {
        self.sliding.lock().map(|s| s.len()).unwrap_or(0)
    }
}

/// Evict the sliding-window key whose most recent timestamp is the oldest
/// (approximate LRU) when `sliding` is at `cap`.
///
/// No-op when the map is under capacity. Empty timestamp lists are treated as
/// "just seen" (`now`) so they are not preferred for eviction.
fn evict_if_full(sliding: &mut HashMap<String, Vec<Instant>>, cap: usize, now: Instant) {
    if sliding.len() < cap {
        return;
    }
    if let Some(oldest) = sliding
        .iter()
        .min_by_key(|(_, v)| v.last().copied().unwrap_or(now))
        .map(|(k, _)| k.clone())
    {
        sliding.remove(&oldest);
    }
}

/// Evict the fixed-window key with the oldest `window_start` when `buckets`
/// is at `cap`. No-op when under capacity.
fn evict_fixed_if_full(buckets: &mut HashMap<String, WindowCounter>, cap: usize) {
    if buckets.len() < cap {
        return;
    }
    if let Some(oldest) = buckets
        .iter()
        .min_by_key(|(_, w)| w.window_start)
        .map(|(k, _)| k.clone())
    {
        buckets.remove(&oldest);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn under_limit_always_allowed() {
        let rl = RateLimitService::new();
        for _ in 0..5 {
            rl.check("k", 10).unwrap();
        }
    }

    #[test]
    fn exceeding_limit_returns_too_many_requests() {
        let rl = RateLimitService::new();
        for _ in 0..3 {
            rl.check("k", 3).unwrap();
        }
        let err = rl.check("k", 3).unwrap_err();
        match err {
            AppError::TooManyRequests { retry_after_secs } => {
                assert!((1..=60).contains(&retry_after_secs));
            }
            other => panic!("expected TooManyRequests, got {other:?}"),
        }
    }

    #[test]
    fn distinct_keys_have_independent_budgets() {
        let rl = RateLimitService::new();
        for _ in 0..3 {
            rl.check("a", 3).unwrap();
            rl.check("b", 3).unwrap();
        }
        assert!(rl.check("a", 3).is_err());
        assert!(rl.check("b", 3).is_err());
    }

    #[test]
    fn window_expiry_releases_budget() {
        let rl = RateLimitService::new();
        let window = Duration::from_millis(50);
        rl.check_window("k", 1, window).unwrap();
        assert!(rl.check_window("k", 1, window).is_err());

        std::thread::sleep(Duration::from_millis(80));
        rl.check_window("k", 1, window)
            .expect("window should reset after expiry");
    }

    #[test]
    fn sliding_window_rejects_burst_across_boundary() {
        let rl = RateLimitService::new();
        let window = Duration::from_millis(100);

        // 3 attempts inside the window — OK.
        rl.check_sliding_window("k", 3, window).unwrap();
        rl.check_sliding_window("k", 3, window).unwrap();
        rl.check_sliding_window("k", 3, window).unwrap();

        // 4th attempt inside the same window — rejected.
        assert!(rl.check_sliding_window("k", 3, window).is_err());

        // After the oldest attempt expires, one slot is released.
        std::thread::sleep(Duration::from_millis(120));
        rl.check_sliding_window("k", 3, window)
            .expect("slot should free after oldest attempt ages out");
    }

    #[test]
    fn sliding_and_fixed_window_are_independent() {
        let rl = RateLimitService::new();
        rl.check("k", 1).unwrap();
        // Fixed window exhausted.
        assert!(rl.check("k", 1).is_err());
        // Sliding window is a separate keyspace.
        rl.check_sliding_window("k", 1, Duration::from_secs(60))
            .expect("sliding map is independent of fixed map");
    }

    #[test]
    fn check_window_respects_max_per_minute_equivalence() {
        // check(k, N) must behave identically to check_window(k, N, 60s).
        let a = RateLimitService::new();
        let b = RateLimitService::new();
        for _ in 0..4 {
            a.check("k", 4).unwrap();
            b.check_window("k", 4, Duration::from_secs(60)).unwrap();
        }
        assert!(a.check("k", 4).is_err());
        assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err());
    }

    #[test]
    fn cloned_limiters_share_buckets() {
        let a = RateLimitService::new();
        let b = a.clone();
        for _ in 0..3 {
            a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
                .unwrap();
        }
        assert!(
            b.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
                .is_err()
        );
    }

    #[test]
    fn independent_new_does_not_share() {
        let a = RateLimitService::new();
        let b = RateLimitService::new();
        for _ in 0..3 {
            a.check_sliding_window("k", 3, Duration::from_secs(3600))
                .unwrap();
        }
        assert!(
            b.check_sliding_window("k", 3, Duration::from_secs(3600))
                .is_ok()
        );
    }

    #[test]
    fn evict_if_full_is_noop_under_cap() {
        let now = Instant::now();
        let mut sliding = HashMap::new();
        sliding.insert("a".into(), vec![now]);
        evict_if_full(&mut sliding, 8, now);
        assert_eq!(sliding.len(), 1);
        assert!(sliding.contains_key("a"));
    }

    #[test]
    fn evict_if_full_drops_key_with_oldest_last_timestamp() {
        let now = Instant::now();
        let mut sliding = HashMap::new();
        for i in 0..8u64 {
            sliding.insert(format!("k{i}"), vec![now + Duration::from_millis(i)]);
        }
        assert_eq!(sliding.len(), 8);

        evict_if_full(&mut sliding, 8, now + Duration::from_secs(1));
        assert_eq!(sliding.len(), 7);
        assert!(!sliding.contains_key("k0"));

        sliding.entry("k8".into()).or_default().push(now);
        assert!(sliding.contains_key("k8"));
        assert!(sliding.len() <= 8);
    }

    #[test]
    fn sliding_window_evicts_lru_instead_of_rejecting_new_key() {
        let rl = RateLimitService::new();
        let window = Duration::from_secs(3600);
        for i in 0..8 {
            rl.check_sliding_window_capped(&format!("k{i}"), 10, window, 8)
                .unwrap();
        }
        assert_eq!(rl.sliding_len(), 8);
        rl.check_sliding_window_capped("k8", 10, window, 8)
            .expect("new key must be admitted via LRU eviction");
        assert!(rl.sliding_len() <= 8);
    }

    #[test]
    fn sliding_window_does_not_evict_when_key_already_tracked() {
        let rl = RateLimitService::new();
        let window = Duration::from_secs(3600);
        for i in 0..8 {
            rl.check_sliding_window_capped(&format!("k{i}"), 2, window, 8)
                .unwrap();
        }
        rl.check_sliding_window_capped("k0", 2, window, 8)
            .expect("existing key at cap must keep its bucket");
        assert_eq!(rl.sliding_len(), 8);
    }

    #[test]
    fn evict_fixed_if_full_drops_oldest_window_start() {
        let now = Instant::now();
        let mut buckets = HashMap::new();
        for i in 0..8u64 {
            buckets.insert(
                format!("k{i}"),
                WindowCounter {
                    count: 1,
                    window_start: now + Duration::from_millis(i),
                },
            );
        }
        evict_fixed_if_full(&mut buckets, 8);
        assert_eq!(buckets.len(), 7);
        assert!(!buckets.contains_key("k0"));
        buckets.insert(
            "k8".into(),
            WindowCounter {
                count: 1,
                window_start: now,
            },
        );
        assert!(buckets.contains_key("k8"));
        assert!(buckets.len() <= 8);
    }

    #[test]
    fn fixed_window_evicts_instead_of_rejecting_new_key() {
        let rl = RateLimitService::new();
        let window = Duration::from_secs(60);
        for i in 0..8 {
            rl.check_window_capped(&format!("k{i}"), 10, window, 8)
                .unwrap();
        }
        assert_eq!(rl.len(), 8);
        rl.check_window_capped("k8", 10, window, 8)
            .expect("new key must be admitted via eviction");
        assert!(rl.len() <= 8);
    }
}