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(®ex::escape(&escaped.to_string()));
26 }
27 }
28 literal => expression.push_str(®ex::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#[async_trait]
39pub trait RateLimitStorage: Send + Sync + std::fmt::Debug {
40 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#[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 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
150pub 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#[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#[derive(Debug, Clone)]
206pub struct PluginRateLimit {
207 pub matches: fn(&str) -> bool,
208 pub limit: EndpointRateLimit,
209}
210
211#[derive(Debug, Clone)]
213pub struct RateLimitConfig {
214 pub default: EndpointRateLimit,
216
217 pub per_endpoint: IndexMap<String, RateLimitRule>,
219
220 pub storage: Option<Arc<dyn RateLimitStorage>>,
222
223 pub max_buckets: usize,
225
226 pub enabled: bool,
228}
229
230#[derive(Debug, Clone)]
232pub struct EndpointRateLimit {
233 pub window_seconds: f64,
235
236 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 #[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#[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 #[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 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#[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 #[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 #[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 #[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 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 assert!(mw.before_request(&req_b).await.unwrap().is_none());
567 }
568
569 #[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 #[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 #[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 #[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