Skip to main content

better_auth_core/middleware/rate_limit/
mod.rs

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