Skip to main content

fraiseql_core/validation/
rate_limiting.rs

1//! Validation-specific rate limiting with per-dimension tracking.
2//!
3//! Provides rate limiting for different types of validation errors:
4//! - `validation_errors`: General field-level validation failures
5//! - `depth_errors`: Query depth limit violations
6//! - `complexity_errors`: Query complexity limit violations
7//! - `malformed_errors`: Malformed query/input errors
8//! - `async_validation_errors`: Async validator failures
9
10use std::{
11    num::NonZeroUsize,
12    sync::{Arc, Mutex},
13};
14
15use lru::LruCache;
16
17use crate::{
18    error::FraiseQLError,
19    utils::clock::{Clock, SystemClock},
20};
21
22/// Maximum number of distinct keys (IPs, user IDs) tracked per rate-limiter
23/// dimension. Prevents unbounded memory growth from IP rotation attacks.
24const MAX_RATE_LIMITER_ENTRIES: usize = 100_000;
25
26/// Rate limit configuration for a single dimension
27#[derive(Debug, Clone)]
28pub struct RateLimitDimension {
29    /// Maximum number of errors allowed in the window
30    pub max_requests: u32,
31    /// Window duration in seconds
32    pub window_secs:  u64,
33}
34
35impl RateLimitDimension {
36    const fn is_rate_limited(&self) -> bool {
37        self.max_requests == 0
38    }
39}
40
41/// Configuration for validation-specific rate limiting
42#[derive(Debug, Clone)]
43pub struct ValidationRateLimitingConfig {
44    /// Enable validation rate limiting
45    pub enabled: bool,
46    /// Validation errors limit
47    pub validation_errors_max_requests: u32,
48    /// Validation errors window in seconds
49    pub validation_errors_window_secs: u64,
50    /// Depth errors limit
51    pub depth_errors_max_requests: u32,
52    /// Depth errors window in seconds
53    pub depth_errors_window_secs: u64,
54    /// Complexity errors limit
55    pub complexity_errors_max_requests: u32,
56    /// Complexity errors window in seconds
57    pub complexity_errors_window_secs: u64,
58    /// Malformed errors limit
59    pub malformed_errors_max_requests: u32,
60    /// Malformed errors window in seconds
61    pub malformed_errors_window_secs: u64,
62    /// Async validation errors limit
63    pub async_validation_errors_max_requests: u32,
64    /// Async validation errors window in seconds
65    pub async_validation_errors_window_secs: u64,
66}
67
68impl Default for ValidationRateLimitingConfig {
69    fn default() -> Self {
70        Self {
71            enabled: true,
72            validation_errors_max_requests: 100,
73            validation_errors_window_secs: 60,
74            depth_errors_max_requests: 50,
75            depth_errors_window_secs: 60,
76            complexity_errors_max_requests: 30,
77            complexity_errors_window_secs: 60,
78            malformed_errors_max_requests: 40,
79            malformed_errors_window_secs: 60,
80            async_validation_errors_max_requests: 60,
81            async_validation_errors_window_secs: 60,
82        }
83    }
84}
85
86/// Request record for tracking
87#[derive(Debug, Clone)]
88struct RequestRecord {
89    /// Number of errors in current window
90    count:        u32,
91    /// Unix timestamp of window start
92    window_start: u64,
93}
94
95/// Single dimension rate limiter
96struct DimensionRateLimiter {
97    records:   Arc<Mutex<LruCache<String, RequestRecord>>>,
98    dimension: RateLimitDimension,
99    clock:     Arc<dyn Clock>,
100}
101
102impl DimensionRateLimiter {
103    #[cfg(test)]
104    fn new(max_requests: u32, window_secs: u64) -> Self {
105        Self::new_with_clock(max_requests, window_secs, Arc::new(SystemClock))
106    }
107
108    fn new_with_clock(max_requests: u32, window_secs: u64, clock: Arc<dyn Clock>) -> Self {
109        #[allow(clippy::expect_used)]
110        // Reason: MAX_RATE_LIMITER_ENTRIES is a non-zero compile-time constant
111        let cap = NonZeroUsize::new(MAX_RATE_LIMITER_ENTRIES)
112            .expect("MAX_RATE_LIMITER_ENTRIES must be > 0");
113        Self {
114            records: Arc::new(Mutex::new(LruCache::new(cap))),
115            dimension: RateLimitDimension {
116                max_requests,
117                window_secs,
118            },
119            clock,
120        }
121    }
122
123    fn check(&self, key: &str) -> Result<(), FraiseQLError> {
124        if self.dimension.is_rate_limited() {
125            return Ok(());
126        }
127
128        let mut records = self.records.lock().expect("records mutex poisoned");
129        let now = self.clock.now_secs();
130
131        // `get_or_insert` promotes the entry to most-recently-used, evicting the
132        // least-recently-used entry when the cache is at capacity.
133        let record = records.get_or_insert_mut(key.to_string(), || RequestRecord {
134            count:        0,
135            window_start: now,
136        });
137
138        // Check if window has expired
139        if now >= record.window_start + self.dimension.window_secs {
140            // Reset window
141            record.count = 1;
142            record.window_start = now;
143            Ok(())
144        } else if record.count < self.dimension.max_requests {
145            // Request allowed
146            record.count += 1;
147            Ok(())
148        } else {
149            // Rate limited
150            Err(FraiseQLError::RateLimited {
151                message:          "Rate limit exceeded for validation errors".to_string(),
152                retry_after_secs: self.dimension.window_secs,
153            })
154        }
155    }
156
157    fn clear(&self) {
158        let mut records = self.records.lock().expect("records mutex poisoned");
159        records.clear();
160    }
161}
162
163impl Clone for DimensionRateLimiter {
164    fn clone(&self) -> Self {
165        Self {
166            records:   Arc::clone(&self.records),
167            dimension: self.dimension.clone(),
168            clock:     Arc::clone(&self.clock),
169        }
170    }
171}
172
173/// Validation-specific rate limiter with per-dimension tracking
174#[derive(Clone)]
175#[allow(clippy::module_name_repetitions, clippy::struct_field_names)] // Reason: RateLimiting prefix provides clarity at call sites
176pub struct ValidationRateLimiter {
177    validation_errors:       DimensionRateLimiter,
178    depth_errors:            DimensionRateLimiter,
179    complexity_errors:       DimensionRateLimiter,
180    malformed_errors:        DimensionRateLimiter,
181    async_validation_errors: DimensionRateLimiter,
182}
183
184impl ValidationRateLimiter {
185    /// Create a new validation rate limiter with the given configuration.
186    pub fn new(config: &ValidationRateLimitingConfig) -> Self {
187        Self::new_with_clock(config, Arc::new(SystemClock))
188    }
189
190    /// Create a validation rate limiter with a custom clock (for testing).
191    pub fn new_with_clock(config: &ValidationRateLimitingConfig, clock: Arc<dyn Clock>) -> Self {
192        Self {
193            validation_errors:       DimensionRateLimiter::new_with_clock(
194                config.validation_errors_max_requests,
195                config.validation_errors_window_secs,
196                Arc::clone(&clock),
197            ),
198            depth_errors:            DimensionRateLimiter::new_with_clock(
199                config.depth_errors_max_requests,
200                config.depth_errors_window_secs,
201                Arc::clone(&clock),
202            ),
203            complexity_errors:       DimensionRateLimiter::new_with_clock(
204                config.complexity_errors_max_requests,
205                config.complexity_errors_window_secs,
206                Arc::clone(&clock),
207            ),
208            malformed_errors:        DimensionRateLimiter::new_with_clock(
209                config.malformed_errors_max_requests,
210                config.malformed_errors_window_secs,
211                Arc::clone(&clock),
212            ),
213            async_validation_errors: DimensionRateLimiter::new_with_clock(
214                config.async_validation_errors_max_requests,
215                config.async_validation_errors_window_secs,
216                clock,
217            ),
218        }
219    }
220
221    /// Check rate limit for validation errors.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`FraiseQLError::RateLimited`] if the key has exceeded the
226    /// validation-errors rate limit within the configured window.
227    pub fn check_validation_errors(&self, key: &str) -> Result<(), FraiseQLError> {
228        self.validation_errors.check(key)
229    }
230
231    /// Check rate limit for depth errors.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`FraiseQLError::RateLimited`] if the key has exceeded the
236    /// depth-errors rate limit within the configured window.
237    pub fn check_depth_errors(&self, key: &str) -> Result<(), FraiseQLError> {
238        self.depth_errors.check(key)
239    }
240
241    /// Check rate limit for complexity errors.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`FraiseQLError::RateLimited`] if the key has exceeded the
246    /// complexity-errors rate limit within the configured window.
247    pub fn check_complexity_errors(&self, key: &str) -> Result<(), FraiseQLError> {
248        self.complexity_errors.check(key)
249    }
250
251    /// Check rate limit for malformed errors.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`FraiseQLError::RateLimited`] if the key has exceeded the
256    /// malformed-errors rate limit within the configured window.
257    pub fn check_malformed_errors(&self, key: &str) -> Result<(), FraiseQLError> {
258        self.malformed_errors.check(key)
259    }
260
261    /// Check rate limit for async validation errors.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`FraiseQLError::RateLimited`] if the key has exceeded the
266    /// async-validation-errors rate limit within the configured window.
267    pub fn check_async_validation_errors(&self, key: &str) -> Result<(), FraiseQLError> {
268        self.async_validation_errors.check(key)
269    }
270
271    /// Clear all rate limiter state
272    pub fn clear(&self) {
273        self.validation_errors.clear();
274        self.depth_errors.clear();
275        self.complexity_errors.clear();
276        self.malformed_errors.clear();
277        self.async_validation_errors.clear();
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_dimension_rate_limiter_allows_within_limit() {
287        let limiter = DimensionRateLimiter::new(3, 60);
288        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 1: {e}"));
289        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 2: {e}"));
290        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 3: {e}"));
291    }
292
293    #[test]
294    fn test_dimension_rate_limiter_rejects_over_limit() {
295        let limiter = DimensionRateLimiter::new(2, 60);
296        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 1: {e}"));
297        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 2: {e}"));
298        assert!(
299            matches!(limiter.check("key"), Err(FraiseQLError::RateLimited { .. })),
300            "expected RateLimited error on request 3, got: {:?}",
301            limiter.check("key")
302        );
303    }
304
305    #[test]
306    fn test_dimension_rate_limiter_per_key() {
307        let limiter = DimensionRateLimiter::new(2, 60);
308        limiter
309            .check("key1")
310            .unwrap_or_else(|e| panic!("expected Ok for key1 request 1: {e}"));
311        limiter
312            .check("key1")
313            .unwrap_or_else(|e| panic!("expected Ok for key1 request 2: {e}"));
314        limiter
315            .check("key2")
316            .unwrap_or_else(|e| panic!("expected Ok for key2 request 1 (independent key): {e}"));
317    }
318
319    #[test]
320    fn test_dimension_rate_limiter_clear() {
321        let limiter = DimensionRateLimiter::new(1, 60);
322        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok before limit: {e}"));
323        assert!(
324            matches!(limiter.check("key"), Err(FraiseQLError::RateLimited { .. })),
325            "expected RateLimited error at limit, got: {:?}",
326            limiter.check("key")
327        );
328        limiter.clear();
329        limiter.check("key").unwrap_or_else(|e| panic!("expected Ok after clear: {e}"));
330    }
331
332    #[test]
333    fn test_config_defaults() {
334        let config = ValidationRateLimitingConfig::default();
335        assert!(config.enabled);
336        assert!(config.validation_errors_max_requests > 0);
337        assert!(config.depth_errors_max_requests > 0);
338        assert!(config.complexity_errors_max_requests > 0);
339        assert!(config.malformed_errors_max_requests > 0);
340        assert!(config.async_validation_errors_max_requests > 0);
341    }
342
343    #[test]
344    fn test_validation_limiter_independent_dimensions() {
345        let config = ValidationRateLimitingConfig::default();
346        let limiter = ValidationRateLimiter::new(&config);
347        let key = "test-key";
348
349        // Fill up validation errors
350        for _ in 0..100 {
351            let _ = limiter.check_validation_errors(key);
352        }
353
354        // Validation errors should be limited
355        assert!(
356            matches!(limiter.check_validation_errors(key), Err(FraiseQLError::RateLimited { .. })),
357            "expected RateLimited after exhausting validation_errors quota"
358        );
359
360        // But other dimensions should still work
361        limiter
362            .check_depth_errors(key)
363            .unwrap_or_else(|e| panic!("depth_errors should still allow: {e}"));
364        limiter
365            .check_complexity_errors(key)
366            .unwrap_or_else(|e| panic!("complexity_errors should still allow: {e}"));
367        limiter
368            .check_malformed_errors(key)
369            .unwrap_or_else(|e| panic!("malformed_errors should still allow: {e}"));
370        limiter
371            .check_async_validation_errors(key)
372            .unwrap_or_else(|e| panic!("async_validation_errors should still allow: {e}"));
373    }
374
375    #[test]
376    fn test_validation_limiter_clone_shares_state() {
377        let config = ValidationRateLimitingConfig::default();
378        let limiter1 = ValidationRateLimiter::new(&config);
379        let limiter2 = limiter1.clone();
380
381        let key = "shared-key";
382
383        for _ in 0..100 {
384            let _ = limiter1.check_validation_errors(key);
385        }
386
387        // limiter2 should see the same limit
388        assert!(
389            matches!(limiter2.check_validation_errors(key), Err(FraiseQLError::RateLimited { .. })),
390            "cloned limiter should share rate limit state"
391        );
392    }
393
394    #[test]
395    fn test_window_rollover_does_not_leak_across_windows() {
396        use std::time::Duration;
397
398        use crate::utils::clock::ManualClock;
399
400        let clock = ManualClock::new();
401        let clock_arc: Arc<dyn Clock> = Arc::new(clock.clone());
402        let config = ValidationRateLimitingConfig {
403            enabled: true,
404            validation_errors_max_requests: 2,
405            validation_errors_window_secs: 60,
406            ..ValidationRateLimitingConfig::default()
407        };
408        let limiter = ValidationRateLimiter::new_with_clock(&config, clock_arc);
409
410        limiter
411            .check_validation_errors("u1")
412            .unwrap_or_else(|e| panic!("expected Ok on 1st request: {e}")); // 1st
413        limiter
414            .check_validation_errors("u1")
415            .unwrap_or_else(|e| panic!("expected Ok on 2nd request: {e}")); // 2nd
416        assert!(
417            matches!(limiter.check_validation_errors("u1"), Err(FraiseQLError::RateLimited { .. })),
418            "expected RateLimited on 3rd request (over limit)"
419        ); // over limit
420
421        clock.advance(Duration::from_secs(61)); // cross the window boundary
422
423        limiter
424            .check_validation_errors("u1")
425            .unwrap_or_else(|e| panic!("expected Ok after window rollover: {e}")); // new window, limit reset
426    }
427
428    /// Sentinel: advancing by exactly `window_secs` must reset the window.
429    ///
430    /// Kills the `>= → >` mutation on the window-expiry check:
431    /// `now >= record.window_start + self.dimension.window_secs`
432    #[test]
433    fn test_window_exact_boundary_triggers_rollover() {
434        use std::time::Duration;
435
436        use crate::utils::clock::ManualClock;
437
438        let clock = ManualClock::new();
439        let clock_arc: Arc<dyn Clock> = Arc::new(clock.clone());
440        let window_secs = 60u64;
441        let max = 2u32;
442        let limiter = DimensionRateLimiter::new_with_clock(max, window_secs, clock_arc);
443
444        // Fill to limit
445        for _ in 0..max {
446            limiter.check("u").unwrap_or_else(|e| panic!("expected Ok filling window: {e}"));
447        }
448        assert!(
449            matches!(limiter.check("u"), Err(FraiseQLError::RateLimited { .. })),
450            "expected RateLimited when over limit"
451        );
452
453        // Advance by EXACTLY window_secs — the `>=` boundary must trigger a reset
454        clock.advance(Duration::from_secs(window_secs));
455
456        limiter
457            .check("u")
458            .unwrap_or_else(|e| panic!("expected Ok at exact window boundary (>= not >): {e}"));
459    }
460
461    /// Sentinel: `max_requests = 0` must disable the limiter (every request allowed).
462    ///
463    /// Kills the `== 0 → != 0` and `== 0 → > 0` mutations on `is_rate_limited()`.
464    #[test]
465    fn test_max_requests_zero_disables_limiter() {
466        let limiter = DimensionRateLimiter::new(0, 60);
467
468        for i in 0..10u32 {
469            limiter
470                .check("key")
471                .unwrap_or_else(|e| panic!("expected Ok with max_requests=0 on request {i}: {e}"));
472        }
473    }
474
475    /// Sentinel: `window_secs = 0` must not panic.
476    ///
477    /// With a zero-length window `now >= window_start + 0` is always true, so
478    /// every call resets the counter and the limiter never triggers.
479    #[test]
480    fn test_window_secs_zero_does_not_panic() {
481        use crate::utils::clock::ManualClock;
482
483        let clock_arc: Arc<dyn Clock> = Arc::new(ManualClock::new());
484        // max_requests > 0 so the limiter is "active", but window_secs = 0
485        let limiter = DimensionRateLimiter::new_with_clock(5, 0, clock_arc);
486
487        // Every request resets the window because now >= window_start + 0 is always true
488        limiter
489            .check("key")
490            .unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (1st): {e}"));
491        limiter
492            .check("key")
493            .unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (2nd): {e}"));
494        limiter
495            .check("key")
496            .unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (3rd): {e}"));
497    }
498}