Skip to main content

alibi_core/middleware/rate_limit/
mod.rs

1pub mod bucket;
2
3use super::Middleware;
4use crate::error::{AuthError, AuthResult};
5use crate::types::{AuthRequest, AuthResponse};
6use async_trait::async_trait;
7use indexmap::IndexMap;
8use std::collections::{BTreeSet, HashMap};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12/// A compiled `per_endpoint` key. Patterns without `*` match exactly.
13#[derive(Debug)]
14enum PathPattern {
15    Exact,
16    Glob(regex::Regex),
17    Invalid,
18}
19
20impl PathPattern {
21    fn new(pattern: &str) -> Self {
22        if !pattern.contains('*') {
23            return Self::Exact;
24        }
25        crate::utils::wildcard::compile_path_glob(pattern).map_or(Self::Invalid, Self::Glob)
26    }
27
28    fn matches(&self, pattern: &str, path: &str) -> AuthResult<bool> {
29        match self {
30            Self::Exact => Ok(pattern == path),
31            Self::Glob(compiled) => Ok(compiled.is_match(path)),
32            Self::Invalid => Err(AuthError::internal("Invalid rate-limit path pattern")),
33        }
34    }
35}
36
37/// A rate-limit backend must atomically decide and consume before returning.
38#[async_trait]
39pub trait RateLimitStorage: Send + Sync + std::fmt::Debug {
40    /// Publish configured windows before serving requests, so persistent
41    /// storage can prune without retiring a longer-lived configured quota.
42    fn observe_window(&self, _window_seconds: f64) {}
43
44    async fn consume(&self, key: &str, rule: &EndpointRateLimit) -> AuthResult<RateLimitDecision>;
45}
46
47#[derive(Debug, Clone, Copy)]
48pub enum RateLimitDecision {
49    Allowed,
50    Blocked { retry_after: f64 },
51}
52
53/// Bounded rolling in-memory storage. Share an `Arc` between auth instances
54/// when they must consume the same client/path quotas.
55#[derive(Debug)]
56pub struct MemoryRateLimitStorage {
57    state: Mutex<RateLimitState>,
58    max_buckets: usize,
59}
60
61impl MemoryRateLimitStorage {
62    #[must_use]
63    pub fn new(max_buckets: usize) -> Self {
64        Self {
65            state: Mutex::new(RateLimitState::default()),
66            max_buckets,
67        }
68    }
69    fn consume_memory(
70        &self,
71        key: String,
72        limit: &EndpointRateLimit,
73    ) -> AuthResult<RateLimitDecision> {
74        let now = Instant::now();
75        let deadline = Duration::try_from_secs_f64(limit.window_seconds)
76            .ok()
77            .and_then(|window| now.checked_add(window));
78        let mut state = self
79            .state
80            .lock()
81            .map_err(|_| AuthError::internal("Rate-limit lock poisoned"))?;
82        while state
83            .expirations
84            .first()
85            .is_some_and(|(expiry, _)| *expiry <= now)
86        {
87            if let Some((_, expired_key)) = state.expirations.pop_first() {
88                _ = state.buckets.remove(&expired_key);
89            }
90        }
91        // Non-positive and NaN windows have no live memory entry in Source.
92        if limit.window_seconds <= 0.0 || limit.window_seconds.is_nan() {
93            if let Some(previous) = state.buckets.remove(&key)
94                && let Some(deadline) = previous.deadline
95            {
96                _ = state.expirations.remove(&(deadline, key));
97            }
98            return Ok(RateLimitDecision::Allowed);
99        }
100        let count = if let Some(previous) = state.buckets.get(&key).copied() {
101            let elapsed = now.duration_since(previous.last_request).as_secs_f64();
102            let expired = elapsed >= limit.window_seconds;
103            if !expired && previous.count >= limit.max_requests {
104                return Ok(RateLimitDecision::Blocked {
105                    retry_after: (limit.window_seconds
106                        - now.duration_since(previous.last_request).as_secs_f64())
107                    .ceil(),
108                });
109            }
110            if let Some(deadline) = previous.deadline {
111                _ = state.expirations.remove(&(deadline, key.clone()));
112            }
113            if expired { 1.0 } else { previous.count + 1.0 }
114        } else {
115            if state.buckets.len() >= self.max_buckets {
116                let remaining = state
117                    .expirations
118                    .first()
119                    .map_or(limit.window_seconds, |(expiry, _)| {
120                        expiry.saturating_duration_since(now).as_secs_f64()
121                    });
122                return Ok(RateLimitDecision::Blocked {
123                    retry_after: remaining.ceil(),
124                });
125            }
126            1.0
127        };
128        _ = state.buckets.insert(
129            key.clone(),
130            Bucket {
131                deadline,
132                last_request: now,
133                count,
134            },
135        );
136        if let Some(deadline) = deadline {
137            _ = state.expirations.insert((deadline, key));
138        }
139        Ok(RateLimitDecision::Allowed)
140    }
141}
142
143#[async_trait]
144impl RateLimitStorage for MemoryRateLimitStorage {
145    async fn consume(&self, key: &str, rule: &EndpointRateLimit) -> AuthResult<RateLimitDecision> {
146        self.consume_memory(key.to_owned(), rule)
147    }
148}
149
150/// Shared fixed-window counter backed by the application's existing cache.
151/// Every attempt increments, including blocked attempts; retries report the full window.
152pub struct CacheRateLimitStorage {
153    cache: Arc<dyn crate::store::secondary_storage::CacheAdapter>,
154}
155
156impl std::fmt::Debug for CacheRateLimitStorage {
157    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        formatter
159            .debug_struct("CacheRateLimitStorage")
160            .finish_non_exhaustive()
161    }
162}
163
164impl CacheRateLimitStorage {
165    #[must_use]
166    pub fn new(cache: Arc<dyn crate::store::secondary_storage::CacheAdapter>) -> Self {
167        Self { cache }
168    }
169}
170
171#[async_trait]
172impl RateLimitStorage for CacheRateLimitStorage {
173    async fn consume(&self, key: &str, rule: &EndpointRateLimit) -> AuthResult<RateLimitDecision> {
174        let ttl = Duration::try_from_secs_f64(rule.window_seconds)
175            .map_err(|_| AuthError::internal("Invalid shared rate-limit window"))?;
176        let count = self
177            .cache
178            .increment(key, ttl)
179            .await
180            .map_err(AuthError::into_callback_failure)?;
181        Ok(if count <= rule.max_requests {
182            RateLimitDecision::Allowed
183        } else {
184            RateLimitDecision::Blocked {
185                retry_after: rule.window_seconds,
186            }
187        })
188    }
189}
190
191/// Application-owned request policy; returning `None` disables this request's limit.
192#[async_trait]
193pub trait RateLimitResolver: Send + Sync + std::fmt::Debug {
194    async fn resolve(
195        &self,
196        request: &AuthRequest,
197        inherited: &EndpointRateLimit,
198    ) -> AuthResult<Option<EndpointRateLimit>>;
199}
200
201#[derive(Debug, Clone)]
202pub enum RateLimitRule {
203    Limit(EndpointRateLimit),
204    Disabled,
205    Dynamic(Arc<dyn RateLimitResolver>),
206}
207
208/// Plugin rules are evaluated in installation order, before application overrides.
209#[derive(Debug, Clone)]
210pub struct PluginRateLimit {
211    pub matches: fn(&str) -> bool,
212    pub limit: EndpointRateLimit,
213}
214
215/// Configuration for the rate limiting middleware.
216#[derive(Debug, Clone)]
217pub struct RateLimitConfig {
218    /// Default limit for endpoints without a built-in special rule or custom override.
219    pub default: EndpointRateLimit,
220
221    /// Ordered path overrides. The first matching rule wins.
222    pub per_endpoint: IndexMap<String, RateLimitRule>,
223
224    /// Shared atomic storage; absent selects bounded process-local memory.
225    pub storage: Option<Arc<dyn RateLimitStorage>>,
226
227    /// Maximum live client/path buckets. New buckets fail closed at capacity.
228    pub max_buckets: usize,
229
230    /// Whether rate limiting is enabled.
231    pub enabled: bool,
232}
233
234/// Rate limit parameters for a single endpoint.
235#[derive(Debug, Clone)]
236pub struct EndpointRateLimit {
237    /// Rolling inactivity window, extended by each allowed request.
238    pub window_seconds: f64,
239
240    /// Maximum number of requests allowed within the window.
241    pub max_requests: f64,
242}
243
244impl Default for RateLimitConfig {
245    fn default() -> Self {
246        Self {
247            default: EndpointRateLimit {
248                window_seconds: 10.0,
249                max_requests: 100.0,
250            },
251            per_endpoint: IndexMap::new(),
252            storage: None,
253            max_buckets: 100_000,
254            enabled: true,
255        }
256    }
257}
258
259impl RateLimitConfig {
260    #[must_use]
261    pub fn new() -> Self {
262        Self::default()
263    }
264
265    #[must_use]
266    pub fn default_limit(mut self, window: Duration, max_requests: u32) -> Self {
267        self.default = EndpointRateLimit {
268            window_seconds: window.as_secs_f64(),
269            max_requests: f64::from(max_requests),
270        };
271        self
272    }
273
274    #[must_use]
275    pub fn endpoint(
276        mut self,
277        path: impl Into<String>,
278        window: Duration,
279        max_requests: u32,
280    ) -> Self {
281        _ = self.per_endpoint.insert(
282            path.into(),
283            RateLimitRule::Limit(EndpointRateLimit {
284                window_seconds: window.as_secs_f64(),
285                max_requests: f64::from(max_requests),
286            }),
287        );
288        self
289    }
290
291    #[must_use]
292    pub fn rule(mut self, path: impl Into<String>, rule: RateLimitRule) -> Self {
293        _ = self.per_endpoint.insert(path.into(), rule);
294        self
295    }
296
297    #[must_use]
298    pub fn storage(mut self, storage: Arc<dyn RateLimitStorage>) -> Self {
299        self.storage = Some(storage);
300        self
301    }
302
303    /// Bound memory without evicting active clients and resetting their quotas.
304    #[must_use]
305    pub const fn max_buckets(mut self, maximum: usize) -> Self {
306        self.max_buckets = maximum;
307        self
308    }
309
310    #[must_use]
311    pub const fn enabled(mut self, enabled: bool) -> Self {
312        self.enabled = enabled;
313        self
314    }
315}
316
317/// Request policies with atomic rolling or fixed-window storage.
318///
319/// Each allowed request extends the current window until its quota is exhausted.
320/// Expired buckets are removed through an ordered expiry index. Both indexes
321/// are bounded by `max_buckets`; no per-request timestamp list is retained.
322/// Independent processes can install shared database/cache storage.
323#[derive(Debug)]
324pub struct RateLimitMiddleware {
325    config: RateLimitConfig,
326    endpoint_patterns: Vec<PathPattern>,
327    base_path: String,
328    memory: MemoryRateLimitStorage,
329    plugin_rules: Vec<PluginRateLimit>,
330}
331
332#[derive(Debug, Default)]
333struct RateLimitState {
334    buckets: HashMap<String, Bucket>,
335    expirations: BTreeSet<(Instant, String)>,
336}
337
338#[derive(Debug, Clone, Copy)]
339struct Bucket {
340    deadline: Option<Instant>,
341    last_request: Instant,
342    count: f64,
343}
344
345const AUTH_LIMIT: EndpointRateLimit = EndpointRateLimit {
346    window_seconds: 10.0,
347    max_requests: 3.0,
348};
349const EMAIL_LIMIT: EndpointRateLimit = EndpointRateLimit {
350    window_seconds: 60.0,
351    max_requests: 3.0,
352};
353
354impl RateLimitMiddleware {
355    #[must_use]
356    pub fn new(mut config: RateLimitConfig) -> Self {
357        if config.default.window_seconds == 0.0 || config.default.window_seconds.is_nan() {
358            config.default.window_seconds = 10.0;
359        }
360        if config.default.max_requests == 0.0 || config.default.max_requests.is_nan() {
361            config.default.max_requests = 100.0;
362        }
363        let memory = MemoryRateLimitStorage::new(config.max_buckets);
364        if let Some(storage) = &config.storage {
365            storage.observe_window(config.default.window_seconds);
366            storage.observe_window(AUTH_LIMIT.window_seconds);
367            storage.observe_window(EMAIL_LIMIT.window_seconds);
368            for rule in config.per_endpoint.values() {
369                if let RateLimitRule::Limit(limit) = rule {
370                    storage.observe_window(limit.window_seconds);
371                }
372            }
373        }
374        let endpoint_patterns = config
375            .per_endpoint
376            .keys()
377            .map(|pattern| PathPattern::new(pattern))
378            .collect();
379        Self {
380            config,
381            endpoint_patterns,
382            base_path: String::new(),
383            memory,
384            plugin_rules: Vec::new(),
385        }
386    }
387
388    /// Match and key routes relative to the application's auth mount path.
389    #[must_use]
390    pub fn with_base_path(mut self, path: impl Into<String>) -> Self {
391        path.into()
392            .trim_end_matches('/')
393            .clone_into(&mut self.base_path);
394        self
395    }
396
397    /// Use the same trusted, normalized IP policy as persisted session metadata.
398    /// Requests without a resolved IP share one per-path bucket.
399    fn client_key(req: &AuthRequest) -> String {
400        let policy = req.extensions().get::<crate::config::IpAddressConfig>();
401        policy
402            .as_ref()
403            .map_or_else(
404                || crate::config::IpAddressConfig::default().resolve_ip(&req.headers),
405                |policy| policy.resolve_ip(&req.headers),
406            )
407            .unwrap_or_else(|| "no-trusted-ip".to_owned())
408    }
409
410    #[must_use]
411    pub fn with_plugin_rules(mut self, rules: Vec<PluginRateLimit>) -> Self {
412        if let Some(storage) = &self.config.storage {
413            for rule in &rules {
414                storage.observe_window(rule.limit.window_seconds);
415            }
416        }
417        self.plugin_rules = rules;
418        self
419    }
420
421    async fn limit_for_request(
422        &self,
423        req: &AuthRequest,
424        path: &str,
425    ) -> AuthResult<Option<EndpointRateLimit>> {
426        let limit = self
427            .plugin_rules
428            .iter()
429            .find(|rule| (rule.matches)(path))
430            .map_or_else(
431                || self.default_limit_for_path(path).clone(),
432                |rule| rule.limit.clone(),
433            );
434        for ((pattern, rule), compiled) in
435            self.config.per_endpoint.iter().zip(&self.endpoint_patterns)
436        {
437            if compiled.matches(pattern, path)? {
438                return match rule {
439                    RateLimitRule::Limit(limit) => Ok(Some(limit.clone())),
440                    RateLimitRule::Disabled => Ok(None),
441                    RateLimitRule::Dynamic(resolver) => resolver.resolve(req, &limit).await,
442                };
443            }
444        }
445        Ok(Some(limit))
446    }
447
448    fn default_limit_for_path(&self, path: &str) -> &EndpointRateLimit {
449        if ["/sign-in", "/sign-up", "/change-password", "/change-email"]
450            .iter()
451            .any(|prefix| path.starts_with(prefix))
452        {
453            return &AUTH_LIMIT;
454        }
455        if matches!(
456            path,
457            "/request-password-reset"
458                | "/send-verification-email"
459                | "/email-otp/send-verification-otp"
460                | "/email-otp/request-password-reset"
461        ) || path.starts_with("/forget-password")
462        {
463            return &EMAIL_LIMIT;
464        }
465        &self.config.default
466    }
467
468    fn blocked(retry_after: f64) -> AuthResponse {
469        AuthResponse::text(
470            429,
471            r#"{"message":"Too many requests. Please try again later."}"#,
472        )
473        .with_header("content-type", "application/json")
474        .with_header(
475            "X-Retry-After",
476            ryu_js::Buffer::new().format(retry_after).to_owned(),
477        )
478    }
479}
480
481#[async_trait]
482impl Middleware for RateLimitMiddleware {
483    fn name(&self) -> &'static str {
484        "rate-limit"
485    }
486
487    async fn before_request(&self, req: &AuthRequest) -> AuthResult<Option<AuthResponse>> {
488        if !self.config.enabled
489            || req
490                .extensions()
491                .get::<crate::config::IpAddressConfig>()
492                .is_some_and(|policy| policy.disable_ip_tracking)
493        {
494            return Ok(None);
495        }
496
497        let requested = req.path.trim_end_matches('/');
498        let path = if requested == self.base_path || requested.is_empty() {
499            "/"
500        } else {
501            requested
502                .strip_prefix(&self.base_path)
503                .filter(|suffix| suffix.starts_with('/'))
504                .unwrap_or(requested)
505        };
506        let Some(limit) = self.limit_for_request(req, path).await? else {
507            return Ok(None);
508        };
509        let ip = Self::client_key(req);
510        let decision = if let Some(storage) = &self.config.storage {
511            storage.consume(&format!("{ip}|{path}"), &limit).await?
512        } else {
513            self.memory.consume(&format!("{ip}|{path}"), &limit).await?
514        };
515        Ok(match decision {
516            RateLimitDecision::Allowed => None,
517            RateLimitDecision::Blocked { retry_after } => Some(Self::blocked(retry_after)),
518        })
519    }
520}
521
522// LCOV_EXCL_START
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::types::HttpMethod;
527    use std::collections::HashMap as StdHashMap;
528
529    fn make_request(path: &str, ip: &str) -> AuthRequest {
530        let mut headers = StdHashMap::new();
531        headers.insert("x-forwarded-for".to_owned(), ip.to_owned());
532        AuthRequest::from_parts(
533            HttpMethod::Post,
534            path.to_owned(),
535            headers,
536            None,
537            StdHashMap::new(),
538        )
539    }
540
541    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
542    #[tokio::test]
543    async fn test_rate_limit_allows_within_limit() {
544        let config = RateLimitConfig::new().default_limit(Duration::from_secs(60), 5);
545        let mw = RateLimitMiddleware::new(config);
546        let req = make_request("/get-session", "1.2.3.4");
547
548        for _ in 0..5 {
549            assert!(mw.before_request(&req).await.unwrap().is_none());
550        }
551    }
552
553    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
554    #[tokio::test]
555    async fn test_rate_limit_blocks_over_limit() {
556        let config = RateLimitConfig::new().default_limit(Duration::from_secs(60), 3);
557        let mw = RateLimitMiddleware::new(config);
558        let req = make_request("/get-session", "1.2.3.4");
559
560        for _ in 0..3 {
561            assert!(mw.before_request(&req).await.unwrap().is_none());
562        }
563
564        let resp = mw.before_request(&req).await.unwrap();
565        assert!(resp.is_some());
566        assert_eq!(resp.unwrap().status, 429);
567    }
568
569    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
570    #[tokio::test]
571    async fn test_rate_limit_per_client() {
572        let config = RateLimitConfig::new().default_limit(Duration::from_secs(60), 2);
573        let mw = RateLimitMiddleware::new(config);
574
575        let req_a = make_request("/get-session", "1.1.1.1");
576        let req_b = make_request("/get-session", "2.2.2.2");
577
578        // Client A uses up its limit
579        for _ in 0..2 {
580            assert!(mw.before_request(&req_a).await.unwrap().is_none());
581        }
582        assert!(mw.before_request(&req_a).await.unwrap().is_some());
583
584        // Client B should still be allowed
585        assert!(mw.before_request(&req_b).await.unwrap().is_none());
586    }
587
588    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
589    #[tokio::test]
590    async fn test_rate_limit_per_endpoint_override() {
591        for (pattern, path, matches) in [
592            ("/sign-in/email", "/sign-in/email", true),
593            ("/sign-in/*", "/sign-in/email", true),
594            ("/email-otp/*", "/email-otp/send-verification-otp", true),
595            ("/email-otp/*", "/email-otp/nested/send", true),
596            ("/literal/?", "/literal/q", false),
597        ] {
598            let config = RateLimitConfig::new()
599                .default_limit(Duration::from_secs(60), 100)
600                .endpoint(pattern, Duration::from_secs(60), 2);
601            let mw = RateLimitMiddleware::new(config);
602            let req = make_request(path, "1.2.3.4");
603            for _ in 0..2 {
604                assert!(mw.before_request(&req).await.unwrap().is_none());
605            }
606            let response = mw.before_request(&req).await.unwrap();
607            assert_eq!(
608                response.as_ref().map(|response| response.status),
609                matches.then_some(429)
610            );
611        }
612    }
613
614    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
615    #[tokio::test]
616    async fn test_rate_limit_disabled() {
617        let config = RateLimitConfig::new()
618            .default_limit(Duration::from_secs(60), 1)
619            .enabled(false);
620        let mw = RateLimitMiddleware::new(config);
621        let req = make_request("/sign-in/email", "1.2.3.4");
622
623        for _ in 0..10 {
624            assert!(mw.before_request(&req).await.unwrap().is_none());
625        }
626    }
627
628    // The real middleware must protect sensitive routes without application overrides.
629    #[tokio::test]
630    async fn default_sensitive_routes_are_limited_before_generic_routes() {
631        for path in [
632            "/sign-in/email",
633            "/sign-up/email",
634            "/change-password",
635            "/change-email",
636            "/request-password-reset",
637            "/send-verification-email",
638            "/forget-password/email",
639            "/email-otp/send-verification-otp",
640            "/email-otp/request-password-reset",
641        ] {
642            let mw = RateLimitMiddleware::new(RateLimitConfig::new());
643            let req = make_request(path, "192.0.2.1");
644            for _ in 0..3 {
645                assert!(mw.before_request(&req).await.unwrap().is_none());
646            }
647            assert_eq!(
648                mw.before_request(&req).await.unwrap().unwrap().status,
649                429,
650                "{path}"
651            );
652        }
653        let mw = RateLimitMiddleware::new(RateLimitConfig::new());
654        let req = make_request("/get-session", "192.0.2.1");
655        for _ in 0..100 {
656            assert!(mw.before_request(&req).await.unwrap().is_none());
657        }
658        let response = mw.before_request(&req).await.unwrap().unwrap();
659        assert_eq!(response.status, 429);
660        assert!(
661            response
662                .headers
663                .get("x-retry-after")
664                .unwrap()
665                .parse::<u64>()
666                .unwrap()
667                <= 10
668        );
669    }
670
671    // Capacity and expiry are observable admission behavior, not private map layout.
672    #[tokio::test]
673    async fn bounded_buckets_preserve_active_quotas_and_recover_after_expiry() {
674        let mw = RateLimitMiddleware::new(
675            RateLimitConfig::new()
676                .default_limit(Duration::from_millis(250), 2)
677                .max_buckets(2),
678        );
679        let first = make_request("/get-session", "192.0.2.1");
680        let second = make_request("/get-session", "192.0.2.2");
681        let third = make_request("/get-session", "192.0.2.3");
682        assert!(mw.before_request(&first).await.unwrap().is_none());
683        assert!(mw.before_request(&second).await.unwrap().is_none());
684        for _ in 0..8 {
685            assert_eq!(
686                mw.before_request(&third).await.unwrap().unwrap().status,
687                429
688            );
689        }
690        assert!(mw.before_request(&first).await.unwrap().is_none());
691        assert_eq!(
692            mw.before_request(&first).await.unwrap().unwrap().status,
693            429
694        );
695        tokio::time::sleep(Duration::from_millis(300)).await;
696        assert!(mw.before_request(&third).await.unwrap().is_none());
697        assert!(mw.before_request(&first).await.unwrap().is_none());
698    }
699
700    #[tokio::test]
701    async fn mounted_and_internal_paths_share_sensitive_route_quotas() {
702        let mw = RateLimitMiddleware::new(RateLimitConfig::new()).with_base_path("/api/auth/");
703        let mounted = make_request("/api/auth/sign-in/email", "192.0.2.1");
704        let internal = make_request("/sign-in/email", "192.0.2.1");
705        assert!(mw.before_request(&mounted).await.unwrap().is_none());
706        assert!(mw.before_request(&internal).await.unwrap().is_none());
707        assert!(mw.before_request(&mounted).await.unwrap().is_none());
708        assert_eq!(
709            mw.before_request(&internal).await.unwrap().unwrap().status,
710            429
711        );
712        let trailing = make_request("/api/auth/sign-in/email///", "192.0.2.1");
713        assert_eq!(
714            mw.before_request(&trailing).await.unwrap().unwrap().status,
715            429
716        );
717        let other_mount = make_request("/unrelated/sign-in/email", "192.0.2.1");
718        for _ in 0..4 {
719            assert!(mw.before_request(&other_mount).await.unwrap().is_none());
720        }
721    }
722
723    #[tokio::test]
724    async fn raw_numeric_limits_preserve_admission_and_retry_headers() {
725        for (window, max, expected) in [
726            (60.0, 0.0, [200, 429, 429]),
727            (60.0, -1.0, [200, 429, 429]),
728            (60.0, 1.5, [200, 200, 429]),
729            (0.0, 1.0, [200, 200, 200]),
730            (-1.0, 1.0, [200, 200, 200]),
731            (f64::NAN, 1.0, [200, 200, 200]),
732            (60.0, f64::NAN, [200, 200, 200]),
733            (f64::INFINITY, 1.0, [200, 429, 429]),
734        ] {
735            let middleware = RateLimitMiddleware::new(RateLimitConfig::new().rule(
736                "/get-session",
737                RateLimitRule::Limit(EndpointRateLimit {
738                    window_seconds: window,
739                    max_requests: max,
740                }),
741            ));
742            let request = make_request("/get-session", "198.51.100.10");
743            for status in expected {
744                let response = middleware.before_request(&request).await.unwrap();
745                assert_eq!(
746                    response.as_ref().map_or(200, |response| response.status),
747                    status
748                );
749                if let Some(response) = response {
750                    assert_eq!(
751                        response.headers.get("x-retry-after").unwrap(),
752                        if window.is_infinite() {
753                            "Infinity"
754                        } else {
755                            "60"
756                        }
757                    );
758                }
759            }
760        }
761    }
762
763    #[tokio::test]
764    async fn independent_instances_share_atomic_memory_quota_and_expiry() {
765        let storage = Arc::new(MemoryRateLimitStorage::new(100));
766        let config = RateLimitConfig::new()
767            .default_limit(Duration::from_millis(250), 3)
768            .storage(storage);
769        let instances = [
770            Arc::new(RateLimitMiddleware::new(config.clone())),
771            Arc::new(RateLimitMiddleware::new(config)),
772        ];
773        let mut tasks = tokio::task::JoinSet::new();
774        for index in 0..32 {
775            let middleware = instances.get(index % 2).unwrap().clone();
776            _ = tasks.spawn(async move {
777                middleware
778                    .before_request(&make_request("/get-session", "198.51.100.11"))
779                    .await
780                    .unwrap()
781            });
782        }
783        let mut admitted = 0;
784        while let Some(result) = tasks.join_next().await {
785            if result.unwrap().is_none() {
786                admitted += 1;
787            }
788        }
789        assert_eq!(admitted, 3);
790        tokio::time::sleep(Duration::from_millis(300)).await;
791        assert!(
792            instances
793                .first()
794                .unwrap()
795                .before_request(&make_request("/get-session", "198.51.100.11"))
796                .await
797                .unwrap()
798                .is_none()
799        );
800    }
801
802    #[cfg(feature = "redis-cache")]
803    #[tokio::test]
804    #[ignore = "requires an isolated Redis server"]
805    async fn independent_redis_instances_share_fixed_ttl_and_backend_errors_fail_closed() {
806        use crate::store::secondary_storage::{CacheAdapter, RedisAdapter};
807        let url = std::env::var("TEST_RATE_LIMIT_REDIS_URL").unwrap();
808        let first = Arc::new(RedisAdapter::new(&url).await.unwrap());
809        let second = Arc::new(RedisAdapter::new(&url).await.unwrap());
810        let storage = [
811            CacheRateLimitStorage::new(first.clone()),
812            CacheRateLimitStorage::new(second.clone()),
813        ];
814        let rule = EndpointRateLimit {
815            window_seconds: 1.0,
816            max_requests: 3.0,
817        };
818        let key = format!("198.51.100.12|/get-session-{}", uuid::Uuid::new_v4());
819        let mut tasks = tokio::task::JoinSet::new();
820        for index in 0..24 {
821            let adapter = if index % 2 == 0 {
822                first.clone()
823            } else {
824                second.clone()
825            };
826            let key = key.clone();
827            _ = tasks.spawn(async move {
828                CacheRateLimitStorage::new(adapter)
829                    .consume(
830                        &key,
831                        &EndpointRateLimit {
832                            window_seconds: 1.0,
833                            max_requests: 3.0,
834                        },
835                    )
836                    .await
837                    .unwrap()
838            });
839        }
840        let mut admitted = 0;
841        while let Some(result) = tasks.join_next().await {
842            if matches!(result.unwrap(), RateLimitDecision::Allowed) {
843                admitted += 1;
844            }
845        }
846        assert_eq!(admitted, 3);
847        assert_eq!(first.get(&key).await.unwrap().unwrap(), "24");
848        tokio::time::sleep(Duration::from_millis(650)).await;
849        assert!(matches!(
850            storage.first().unwrap().consume(&key, &rule).await.unwrap(),
851            RateLimitDecision::Blocked { retry_after: 1.0 }
852        ));
853        tokio::time::sleep(Duration::from_millis(450)).await;
854        assert!(matches!(
855            storage.last().unwrap().consume(&key, &rule).await.unwrap(),
856            RateLimitDecision::Allowed
857        ));
858        assert_eq!(second.get(&key).await.unwrap().unwrap(), "1");
859        first.delete(&key).await.unwrap();
860        let invalid_key = format!("invalid-{key}");
861        assert!(
862            first
863                .increment(&invalid_key, Duration::from_millis(300))
864                .await
865                .is_err()
866        );
867        assert!(first.get(&invalid_key).await.unwrap().is_none());
868
869        let unavailable = Arc::new(RedisAdapter::new("redis://127.0.0.1:9").await.unwrap());
870        let middleware = RateLimitMiddleware::new(
871            RateLimitConfig::new().storage(Arc::new(CacheRateLimitStorage::new(unavailable))),
872        );
873        let error = middleware
874            .before_request(&make_request("/get-session", "198.51.100.12"))
875            .await
876            .unwrap_err()
877            .to_auth_response();
878        assert_eq!(error.status, 500);
879        assert!(!String::from_utf8_lossy(&error.body).contains("Redis"));
880    }
881}
882// LCOV_EXCL_STOP