1use std::collections::HashMap;
72use std::future::Future;
73use std::pin::Pin;
74use std::sync::atomic::{AtomicU64, Ordering};
75use std::time::{SystemTime, UNIX_EPOCH};
76
77use a2a_protocol_types::error::{A2aError, A2aResult};
78use tokio::sync::RwLock;
79
80use crate::call_context::CallContext;
81use crate::error::{ServerError, ServerResult};
82use crate::interceptor::ServerInterceptor;
83
84#[derive(Debug, Clone)]
86pub struct RateLimitConfig {
87 pub requests_per_window: u64,
91
92 pub window_secs: u64,
96
97 pub trusted_proxy_hops: usize,
112
113 pub max_buckets: usize,
120}
121
122pub const DEFAULT_MAX_BUCKETS: usize = 10_000;
124
125impl Default for RateLimitConfig {
126 fn default() -> Self {
127 Self {
128 requests_per_window: 100,
129 window_secs: 60,
130 trusted_proxy_hops: 0,
131 max_buckets: DEFAULT_MAX_BUCKETS,
132 }
133 }
134}
135
136struct CallerBucket {
138 window_start: AtomicU64,
140 count: AtomicU64,
142}
143
144pub struct RateLimitInterceptor {
155 config: RateLimitConfig,
156 buckets: RwLock<HashMap<String, CallerBucket>>,
157 check_count: AtomicU64,
159}
160
161const CLEANUP_INTERVAL: u64 = 256;
163
164impl std::fmt::Debug for RateLimitInterceptor {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("RateLimitInterceptor")
167 .field("config", &self.config)
168 .finish_non_exhaustive()
169 }
170}
171
172impl RateLimitInterceptor {
173 pub fn new(config: RateLimitConfig) -> ServerResult<Self> {
182 if config.requests_per_window == 0 {
183 return Err(ServerError::InvalidParams(
184 "rate limit requests_per_window must be greater than zero".into(),
185 ));
186 }
187 if config.window_secs == 0 {
188 return Err(ServerError::InvalidParams(
189 "rate limit window_secs must be greater than zero".into(),
190 ));
191 }
192 if config.max_buckets == 0 {
193 return Err(ServerError::InvalidParams(
194 "rate limit max_buckets must be greater than zero".into(),
195 ));
196 }
197 Ok(Self {
198 config,
199 buckets: RwLock::new(HashMap::new()),
200 check_count: AtomicU64::new(0),
201 })
202 }
203
204 fn caller_key(&self, ctx: &CallContext) -> String {
209 if let Some(identity) = ctx.caller_identity() {
210 return identity.to_owned();
211 }
212 let hops = self.config.trusted_proxy_hops;
213 if hops > 0 {
214 if let Some(xff) = ctx.http_headers().get("x-forwarded-for") {
215 let entries: Vec<&str> = xff
216 .split(',')
217 .map(str::trim)
218 .filter(|e| !e.is_empty())
219 .collect();
220 if entries.len() >= hops {
224 return canonicalize_caller_ip(entries[entries.len() - hops]);
225 }
226 }
230 }
231 "anonymous".to_string()
232 }
233
234 const fn window_number(&self, now_secs: u64) -> u64 {
236 now_secs / self.config.window_secs
237 }
238
239 fn evict_stale(buckets: &mut HashMap<String, CallerBucket>, current_window: u64) {
241 buckets.retain(|_, bucket| {
242 bucket.window_start.load(Ordering::Relaxed) >= current_window.saturating_sub(1)
243 });
244 }
245
246 async fn cleanup_stale_buckets(&self) {
251 let now_secs = SystemTime::now()
252 .duration_since(UNIX_EPOCH)
253 .unwrap_or_default()
254 .as_secs();
255 let current_window = self.window_number(now_secs);
256
257 let mut buckets = self.buckets.write().await;
258 Self::evict_stale(&mut buckets, current_window);
259 }
260
261 #[allow(clippy::too_many_lines)]
263 fn admit_within_window(&self, bucket: &CallerBucket) -> A2aResult<()> {
274 let count = bucket.count.fetch_add(1, Ordering::Relaxed) + 1;
275 if count > self.config.requests_per_window {
276 return Err(A2aError::internal(format!(
277 "rate limit exceeded: {} requests per {} seconds",
278 self.config.requests_per_window, self.config.window_secs
279 )));
280 }
281 Ok(())
282 }
283
284 fn admit_or_roll_window(&self, bucket: &CallerBucket, current_window: u64) -> A2aResult<()> {
300 if bucket.window_start.load(Ordering::Acquire) == current_window {
301 return self.admit_within_window(bucket);
302 }
303 bucket.window_start.store(current_window, Ordering::Release);
304 bucket.count.store(1, Ordering::Release);
305 Ok(())
306 }
307
308 async fn create_or_join_bucket(&self, key: &str, current_window: u64) -> A2aResult<()> {
319 let mut buckets = self.buckets.write().await;
320 if let Some(bucket) = buckets.get(key) {
322 return self.admit_or_roll_window(bucket, current_window);
323 }
324 if buckets.len() >= self.config.max_buckets {
325 Self::evict_stale(&mut buckets, current_window);
327 if buckets.len() >= self.config.max_buckets {
328 return Err(A2aError::internal(format!(
329 "rate limiter caller capacity exhausted ({} buckets); request rejected",
330 self.config.max_buckets
331 )));
332 }
333 }
334 buckets.insert(
335 key.to_string(),
336 CallerBucket {
337 window_start: AtomicU64::new(current_window),
338 count: AtomicU64::new(1),
339 },
340 );
341 drop(buckets);
342 Ok(())
343 }
344
345 async fn check(&self, key: &str) -> A2aResult<()> {
346 let now_secs = SystemTime::now()
347 .duration_since(UNIX_EPOCH)
348 .unwrap_or_default()
349 .as_secs();
350 let current_window = self.window_number(now_secs);
351
352 let count = self.check_count.fetch_add(1, Ordering::Relaxed);
354 if count > 0 && count.is_multiple_of(CLEANUP_INTERVAL) {
355 self.cleanup_stale_buckets().await;
356 }
357
358 {
362 let buckets = self.buckets.read().await;
363 if let Some(bucket) = buckets.get(key) {
364 loop {
368 let bucket_window = bucket.window_start.load(Ordering::Acquire);
369 if bucket_window == current_window {
370 return self.admit_within_window(bucket);
371 }
372 if bucket
376 .window_start
377 .compare_exchange(
378 bucket_window,
379 current_window,
380 Ordering::AcqRel,
381 Ordering::Acquire,
382 )
383 .is_ok()
384 {
385 bucket.count.store(1, Ordering::Release);
386 return Ok(());
387 }
388 }
390 }
391 }
392
393 self.create_or_join_bucket(key, current_window).await
394 }
395}
396
397impl ServerInterceptor for RateLimitInterceptor {
398 fn before<'a>(
399 &'a self,
400 ctx: &'a CallContext,
401 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
402 Box::pin(async move {
403 let key = self.caller_key(ctx);
404 self.check(&key).await
405 })
406 }
407
408 fn after<'a>(
409 &'a self,
410 _ctx: &'a CallContext,
411 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
412 Box::pin(async { Ok(()) })
413 }
414}
415
416fn canonicalize_caller_ip(entry: &str) -> String {
426 use std::net::IpAddr;
427 let trimmed = entry.trim().trim_start_matches('[').trim_end_matches(']');
428 match trimmed.parse::<IpAddr>() {
429 Ok(IpAddr::V6(v6)) => v6
430 .to_ipv4_mapped()
431 .map_or_else(|| IpAddr::V6(v6).to_string(), |v4| v4.to_string()),
432 Ok(ip) => ip.to_string(),
433 Err(_) => trimmed.to_string(),
434 }
435}
436
437#[cfg(test)]
438mod double_check_tests {
439 use super::{CallerBucket, RateLimitConfig, RateLimitInterceptor};
440 use std::sync::atomic::{AtomicU64, Ordering};
441
442 fn limiter(limit: u64) -> RateLimitInterceptor {
443 RateLimitInterceptor::new(RateLimitConfig {
444 requests_per_window: limit,
445 window_secs: 60,
446 ..RateLimitConfig::default()
447 })
448 .expect("valid config")
449 }
450
451 fn bucket(window: u64, count: u64) -> CallerBucket {
452 CallerBucket {
453 window_start: AtomicU64::new(window),
454 count: AtomicU64::new(count),
455 }
456 }
457
458 #[test]
464 fn same_window_counts_the_request_rather_than_resetting() {
465 let rl = limiter(3);
466 let b = bucket(100, 2);
467
468 assert!(
469 rl.admit_or_roll_window(&b, 100).is_ok(),
470 "the third request of three is still within the limit"
471 );
472 assert_eq!(
473 b.count.load(Ordering::Acquire),
474 3,
475 "an in-window request must increment the counter, not reset it"
476 );
477 assert_eq!(
478 b.window_start.load(Ordering::Acquire),
479 100,
480 "the window must not roll while it is still current"
481 );
482
483 assert!(
486 rl.admit_or_roll_window(&b, 100).is_err(),
487 "the fourth request of three must be rejected"
488 );
489 }
490
491 #[test]
494 fn advanced_window_rolls_and_restarts_the_count() {
495 let rl = limiter(3);
496 let b = bucket(100, 99);
497
498 assert!(
499 rl.admit_or_roll_window(&b, 101).is_ok(),
500 "a request in a fresh window is admitted regardless of the old count"
501 );
502 assert_eq!(b.count.load(Ordering::Acquire), 1, "the count restarts");
503 assert_eq!(
504 b.window_start.load(Ordering::Acquire),
505 101,
506 "the window rolls forward"
507 );
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use std::collections::HashMap;
515
516 #[test]
517 fn caller_ip_canonicalization_collapses_equivalent_forms() {
518 assert_eq!(canonicalize_caller_ip("::ffff:203.0.113.7"), "203.0.113.7");
520 assert_eq!(canonicalize_caller_ip("203.0.113.7"), "203.0.113.7");
521 assert_eq!(
523 canonicalize_caller_ip("[2001:db8::1]"),
524 canonicalize_caller_ip("2001:0db8:0000:0000:0000:0000:0000:0001")
525 );
526 assert_eq!(canonicalize_caller_ip(" not-an-ip "), "not-an-ip");
528 }
529
530 fn make_ctx(identity: Option<&str>) -> CallContext {
531 let mut ctx = CallContext::new("message/send");
532 if let Some(id) = identity {
533 ctx = ctx.with_caller_identity(id.to_owned());
534 }
535 ctx
536 }
537
538 #[tokio::test]
539 async fn allows_requests_within_limit() {
540 let limiter = RateLimitInterceptor::new(RateLimitConfig {
541 requests_per_window: 5,
542 window_secs: 60,
543 ..RateLimitConfig::default()
544 })
545 .expect("valid config");
546 let ctx = make_ctx(Some("user-1"));
547 for _ in 0..5 {
548 assert!(limiter.before(&ctx).await.is_ok());
549 }
550 }
551
552 #[tokio::test]
553 async fn rejects_requests_over_limit() {
554 let limiter = RateLimitInterceptor::new(RateLimitConfig {
555 requests_per_window: 3,
556 window_secs: 60,
557 ..RateLimitConfig::default()
558 })
559 .expect("valid config");
560 let ctx = make_ctx(Some("user-2"));
561 for _ in 0..3 {
562 assert!(limiter.before(&ctx).await.is_ok());
563 }
564 let result = limiter.before(&ctx).await;
565 assert!(result.is_err());
566 }
567
568 #[tokio::test]
569 async fn different_callers_have_separate_limits() {
570 let limiter = RateLimitInterceptor::new(RateLimitConfig {
571 requests_per_window: 2,
572 window_secs: 60,
573 ..RateLimitConfig::default()
574 })
575 .expect("valid config");
576 let ctx_a = make_ctx(Some("alice"));
577 let ctx_b = make_ctx(Some("bob"));
578
579 assert!(limiter.before(&ctx_a).await.is_ok());
580 assert!(limiter.before(&ctx_a).await.is_ok());
581 assert!(limiter.before(&ctx_a).await.is_err()); assert!(limiter.before(&ctx_b).await.is_ok());
585 assert!(limiter.before(&ctx_b).await.is_ok());
586 }
587
588 #[tokio::test]
589 async fn anonymous_fallback_when_no_identity() {
590 let limiter = RateLimitInterceptor::new(RateLimitConfig {
591 requests_per_window: 1,
592 window_secs: 60,
593 ..RateLimitConfig::default()
594 })
595 .expect("valid config");
596 let ctx = make_ctx(None);
597 assert!(limiter.before(&ctx).await.is_ok());
598 assert!(limiter.before(&ctx).await.is_err());
599 }
600
601 #[tokio::test]
605 async fn default_config_ignores_forged_x_forwarded_for() {
606 let limiter = RateLimitInterceptor::new(RateLimitConfig {
607 requests_per_window: 1,
608 window_secs: 60,
609 ..RateLimitConfig::default()
610 })
611 .expect("valid config");
612 let ctx1 = CallContext::new("message/send").with_http_header("x-forwarded-for", "10.0.0.1");
615 let ctx2 = CallContext::new("message/send").with_http_header("x-forwarded-for", "10.0.0.2");
616 assert!(limiter.before(&ctx1).await.is_ok());
617 assert!(
618 limiter.before(&ctx2).await.is_err(),
619 "forged x-forwarded-for must not evade the limit"
620 );
621 assert_eq!(limiter.buckets.read().await.len(), 1);
623 }
624
625 #[tokio::test]
629 async fn trusted_hop_uses_rightmost_entry_and_resists_spoofing() {
630 let limiter = RateLimitInterceptor::new(RateLimitConfig {
631 requests_per_window: 1,
632 window_secs: 60,
633 trusted_proxy_hops: 1,
634 ..RateLimitConfig::default()
635 })
636 .expect("valid config");
637 let ctx1 = CallContext::new("message/send")
639 .with_http_header("x-forwarded-for", "6.6.6.1, 203.0.113.7");
640 let ctx2 = CallContext::new("message/send")
641 .with_http_header("x-forwarded-for", "6.6.6.2, 203.0.113.7");
642 assert!(limiter.before(&ctx1).await.is_ok());
643 assert!(
644 limiter.before(&ctx2).await.is_err(),
645 "spoofed left-hand entries must map to the same real client"
646 );
647 let ctx3 =
649 CallContext::new("message/send").with_http_header("x-forwarded-for", "203.0.113.8");
650 assert!(limiter.before(&ctx3).await.is_ok());
651 }
652
653 #[tokio::test]
655 async fn trusted_hops_two_takes_second_from_right() {
656 let limiter = RateLimitInterceptor::new(RateLimitConfig {
657 requests_per_window: 1,
658 window_secs: 60,
659 trusted_proxy_hops: 2,
660 ..RateLimitConfig::default()
661 })
662 .expect("valid config");
663 let ctx1 = CallContext::new("message/send")
665 .with_http_header("x-forwarded-for", "6.6.6.1, 198.51.100.9, 10.0.0.5");
666 let ctx2 = CallContext::new("message/send")
667 .with_http_header("x-forwarded-for", "6.6.6.2, 198.51.100.9, 10.0.0.5");
668 assert!(limiter.before(&ctx1).await.is_ok());
669 assert!(
670 limiter.before(&ctx2).await.is_err(),
671 "same client, same bucket"
672 );
673 }
674
675 #[tokio::test]
678 async fn short_xff_chain_falls_back_to_anonymous() {
679 let limiter = RateLimitInterceptor::new(RateLimitConfig {
680 requests_per_window: 1,
681 window_secs: 60,
682 trusted_proxy_hops: 3,
683 ..RateLimitConfig::default()
684 })
685 .expect("valid config");
686 let ctx1 = CallContext::new("message/send").with_http_header("x-forwarded-for", "1.2.3.4");
687 let ctx2 = CallContext::new("message/send").with_http_header("x-forwarded-for", "5.6.7.8");
688 assert!(limiter.before(&ctx1).await.is_ok());
689 assert!(
690 limiter.before(&ctx2).await.is_err(),
691 "short chains must share the anonymous bucket, not be trusted"
692 );
693 }
694
695 #[test]
700 fn new_rejects_zero_window_secs() {
701 let err = RateLimitInterceptor::new(RateLimitConfig {
702 window_secs: 0,
703 ..RateLimitConfig::default()
704 })
705 .expect_err("zero window_secs must be rejected");
706 assert!(err.to_string().contains("window_secs"), "got: {err}");
707 }
708
709 #[test]
710 fn new_rejects_zero_requests_per_window() {
711 let err = RateLimitInterceptor::new(RateLimitConfig {
712 requests_per_window: 0,
713 ..RateLimitConfig::default()
714 })
715 .expect_err("zero requests_per_window must be rejected");
716 assert!(
717 err.to_string().contains("requests_per_window"),
718 "got: {err}"
719 );
720 }
721
722 #[test]
723 fn new_rejects_zero_max_buckets() {
724 let err = RateLimitInterceptor::new(RateLimitConfig {
725 max_buckets: 0,
726 ..RateLimitConfig::default()
727 })
728 .expect_err("zero max_buckets must be rejected");
729 assert!(err.to_string().contains("max_buckets"), "got: {err}");
730 }
731
732 #[tokio::test]
737 async fn bucket_map_is_bounded() {
738 let limiter = RateLimitInterceptor::new(RateLimitConfig {
739 requests_per_window: 10,
740 window_secs: 60,
741 max_buckets: 2,
742 ..RateLimitConfig::default()
743 })
744 .expect("valid config");
745 assert!(limiter.before(&make_ctx(Some("a"))).await.is_ok());
746 assert!(limiter.before(&make_ctx(Some("b"))).await.is_ok());
747 let err = limiter
748 .before(&make_ctx(Some("c")))
749 .await
750 .expect_err("third caller must be rejected at capacity");
751 assert!(err.to_string().contains("capacity"), "got: {err}");
752 assert_eq!(limiter.buckets.read().await.len(), 2);
753 assert!(limiter.before(&make_ctx(Some("a"))).await.is_ok());
755 }
756
757 #[tokio::test]
760 async fn full_map_evicts_stale_buckets_before_rejecting() {
761 let limiter = RateLimitInterceptor::new(RateLimitConfig {
762 requests_per_window: 10,
763 window_secs: 60,
764 max_buckets: 2,
765 ..RateLimitConfig::default()
766 })
767 .expect("valid config");
768 assert!(limiter.before(&make_ctx(Some("live"))).await.is_ok());
770 {
771 let mut buckets = limiter.buckets.write().await;
772 buckets.insert(
773 "ancient".to_string(),
774 CallerBucket {
775 window_start: AtomicU64::new(0),
776 count: AtomicU64::new(1),
777 },
778 );
779 }
780 assert!(
782 limiter.before(&make_ctx(Some("newcomer"))).await.is_ok(),
783 "stale bucket should be evicted to admit the new caller"
784 );
785 let buckets = limiter.buckets.read().await;
786 assert!(!buckets.contains_key("ancient"));
787 assert!(buckets.contains_key("live"));
788 assert!(buckets.contains_key("newcomer"));
789 drop(buckets);
790 }
791
792 #[tokio::test]
795 async fn concurrent_distinct_callers_respect_bucket_cap() {
796 use std::sync::Arc;
797
798 let limiter = RateLimitInterceptor::new(RateLimitConfig {
799 requests_per_window: 10,
800 window_secs: 60,
801 max_buckets: 10,
802 ..RateLimitConfig::default()
803 })
804 .expect("valid config");
805 let limiter = Arc::new(limiter);
806
807 let mut handles = Vec::new();
808 for i in 0..50 {
809 let lim = Arc::clone(&limiter);
810 handles.push(tokio::spawn(async move {
811 let ctx =
812 CallContext::new("message/send").with_caller_identity(format!("user-{i}"));
813 lim.before(&ctx).await
814 }));
815 }
816
817 let mut ok_count = 0;
818 let mut err_count = 0;
819 for handle in handles {
820 match handle.await.unwrap() {
821 Ok(()) => ok_count += 1,
822 Err(_) => err_count += 1,
823 }
824 }
825 assert_eq!(ok_count, 10, "exactly max_buckets callers admitted");
826 assert_eq!(err_count, 40);
827 assert_eq!(limiter.buckets.read().await.len(), 10);
828 }
829
830 #[tokio::test]
831 async fn concurrent_rate_limit_checks() {
832 use std::sync::Arc;
833
834 let limiter = Arc::new(
835 RateLimitInterceptor::new(RateLimitConfig {
836 requests_per_window: 100,
837 window_secs: 60,
838 ..RateLimitConfig::default()
839 })
840 .expect("valid config"),
841 );
842
843 let mut handles = Vec::new();
845 for _ in 0..200 {
846 let lim = Arc::clone(&limiter);
847 handles.push(tokio::spawn(async move {
848 let ctx =
849 CallContext::new("message/send").with_caller_identity("concurrent-user".into());
850 lim.before(&ctx).await
851 }));
852 }
853
854 let mut ok_count = 0;
855 let mut err_count = 0;
856 for handle in handles {
857 match handle.await.unwrap() {
858 Ok(()) => ok_count += 1,
859 Err(_) => err_count += 1,
860 }
861 }
862
863 assert_eq!(ok_count, 100, "expected 100 allowed, got {ok_count}");
865 assert_eq!(err_count, 100, "expected 100 rejected, got {err_count}");
866 }
867
868 #[tokio::test]
869 async fn stale_bucket_cleanup() {
870 let limiter = RateLimitInterceptor::new(RateLimitConfig {
871 requests_per_window: 10,
872 window_secs: 60,
873 ..RateLimitConfig::default()
874 })
875 .expect("valid config");
876
877 let ctx_a = make_ctx(Some("stale-a"));
879 let ctx_b = make_ctx(Some("stale-b"));
880 assert!(limiter.before(&ctx_a).await.is_ok());
881 assert!(limiter.before(&ctx_b).await.is_ok());
882
883 assert_eq!(limiter.buckets.read().await.len(), 2);
884
885 limiter.cleanup_stale_buckets().await;
887 assert_eq!(
888 limiter.buckets.read().await.len(),
889 2,
890 "current-window buckets should not be evicted"
891 );
892 }
893
894 #[test]
895 fn debug_format_includes_config() {
896 let limiter = RateLimitInterceptor::new(RateLimitConfig {
897 requests_per_window: 42,
898 window_secs: 10,
899 ..RateLimitConfig::default()
900 })
901 .expect("valid config");
902 let debug = format!("{limiter:?}");
903 assert!(
904 debug.contains("RateLimitInterceptor"),
905 "Debug output should contain struct name"
906 );
907 assert!(
908 debug.contains("config"),
909 "Debug output should contain config field"
910 );
911 }
912
913 #[test]
915 fn default_config_values() {
916 let config = RateLimitConfig::default();
917 assert_eq!(config.requests_per_window, 100);
918 assert_eq!(config.window_secs, 60);
919 }
920
921 #[tokio::test]
923 async fn after_hook_is_noop() {
924 let limiter = RateLimitInterceptor::new(RateLimitConfig::default()).expect("valid config");
925 let ctx = make_ctx(Some("user"));
926 let result = limiter.after(&ctx).await;
927 assert_eq!(result.unwrap(), (), "after hook should return Ok(())");
928 }
929
930 #[test]
931 fn window_number_correctness() {
932 let limiter = RateLimitInterceptor::new(RateLimitConfig {
933 requests_per_window: 10,
934 window_secs: 60,
935 ..RateLimitConfig::default()
936 })
937 .expect("valid config");
938
939 assert_eq!(limiter.window_number(0), 0);
941 assert_eq!(limiter.window_number(59), 0);
943 assert_eq!(limiter.window_number(60), 1);
945 assert_eq!(limiter.window_number(120), 2);
947 assert_eq!(limiter.window_number(61), 1);
949 }
950
951 #[tokio::test]
952 async fn cleanup_stale_buckets_removes_old_entries() {
953 let limiter = RateLimitInterceptor::new(RateLimitConfig {
954 requests_per_window: 100,
955 window_secs: 60,
956 ..RateLimitConfig::default()
957 })
958 .expect("valid config");
959
960 {
962 let mut buckets = limiter.buckets.write().await;
963 buckets.insert(
964 "ancient-user".to_string(),
965 CallerBucket {
966 window_start: AtomicU64::new(0), count: AtomicU64::new(5),
968 },
969 );
970 }
971 assert_eq!(limiter.buckets.read().await.len(), 1);
972
973 limiter.cleanup_stale_buckets().await;
975 assert_eq!(
976 limiter.buckets.read().await.len(),
977 0,
978 "ancient bucket should be evicted"
979 );
980 }
981
982 #[tokio::test]
983 async fn check_triggers_cleanup_at_interval() {
984 let limiter = RateLimitInterceptor::new(RateLimitConfig {
985 requests_per_window: 10000,
986 window_secs: 60,
987 ..RateLimitConfig::default()
988 })
989 .expect("valid config");
990
991 {
993 let mut buckets = limiter.buckets.write().await;
994 buckets.insert(
995 "stale-for-cleanup".to_string(),
996 CallerBucket {
997 window_start: AtomicU64::new(0),
998 count: AtomicU64::new(1),
999 },
1000 );
1001 }
1002
1003 limiter
1006 .check_count
1007 .store(CLEANUP_INTERVAL, Ordering::Relaxed);
1008
1009 let ctx = make_ctx(Some("cleanup-trigger-user"));
1010 assert!(limiter.before(&ctx).await.is_ok());
1012
1013 let buckets = limiter.buckets.read().await;
1015 let has_stale = buckets.contains_key("stale-for-cleanup");
1016 drop(buckets);
1017 assert!(
1018 !has_stale,
1019 "stale bucket should be cleaned up after CLEANUP_INTERVAL checks"
1020 );
1021 }
1022
1023 #[tokio::test]
1024 async fn slow_path_double_check_same_window() {
1025 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1029 requests_per_window: 2,
1030 window_secs: 60,
1031 ..RateLimitConfig::default()
1032 })
1033 .expect("valid config");
1034
1035 let ctx = make_ctx(Some("race-user"));
1036 assert!(limiter.before(&ctx).await.is_ok());
1038 assert!(limiter.before(&ctx).await.is_ok());
1040 assert!(limiter.before(&ctx).await.is_err());
1042 }
1043
1044 #[tokio::test]
1047 async fn slow_path_double_check_stale_window() {
1048 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1049 requests_per_window: 10,
1050 window_secs: 60,
1051 ..RateLimitConfig::default()
1052 })
1053 .expect("valid config");
1054
1055 let key = "slow-path-stale";
1058 {
1059 let mut buckets = limiter.buckets.write().await;
1060 buckets.insert(
1061 key.to_string(),
1062 CallerBucket {
1063 window_start: AtomicU64::new(1), count: AtomicU64::new(5),
1065 },
1066 );
1067 }
1068
1069 let result = limiter.check(key).await;
1073 assert!(
1074 result.is_ok(),
1075 "slow-path stale-window reset should succeed"
1076 );
1077
1078 assert_eq!(
1080 limiter
1081 .buckets
1082 .read()
1083 .await
1084 .get(key)
1085 .expect("bucket should exist")
1086 .count
1087 .load(Ordering::Relaxed),
1088 1,
1089 "count should be reset to 1 after window advance"
1090 );
1091 }
1092
1093 #[tokio::test]
1096 async fn slow_path_rate_limit_exceeded() {
1097 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1098 requests_per_window: 1,
1099 window_secs: 60,
1100 ..RateLimitConfig::default()
1101 })
1102 .expect("valid config");
1103
1104 let now_secs = SystemTime::now()
1105 .duration_since(UNIX_EPOCH)
1106 .unwrap()
1107 .as_secs();
1108 let current_window = limiter.window_number(now_secs);
1109
1110 let key = "slow-path-exceeded";
1112 {
1113 let mut buckets = limiter.buckets.write().await;
1114 buckets.insert(
1115 key.to_string(),
1116 CallerBucket {
1117 window_start: AtomicU64::new(current_window),
1118 count: AtomicU64::new(1), },
1120 );
1121 }
1122
1123 let result = limiter.check(key).await;
1126 assert!(
1127 result.is_err(),
1128 "slow-path should reject when count exceeds limit"
1129 );
1130 }
1131
1132 #[tokio::test]
1134 async fn fast_path_rate_limit_exceeded() {
1135 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1136 requests_per_window: 2,
1137 window_secs: 60,
1138 ..RateLimitConfig::default()
1139 })
1140 .expect("valid config");
1141
1142 let ctx = make_ctx(Some("fast-path-user"));
1144 assert!(limiter.before(&ctx).await.is_ok());
1145 assert!(limiter.before(&ctx).await.is_ok());
1146 let result = limiter.before(&ctx).await;
1148 assert!(
1149 result.is_err(),
1150 "fast-path should reject when count exceeds limit"
1151 );
1152 let err = result.unwrap_err();
1153 assert!(
1154 err.to_string().contains("rate limit exceeded"),
1155 "error message should mention rate limit exceeded, got: {err}"
1156 );
1157 }
1158
1159 #[tokio::test]
1162 async fn fast_path_window_advancement_resets_count() {
1163 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1164 requests_per_window: 1,
1165 window_secs: 60,
1166 ..RateLimitConfig::default()
1167 })
1168 .expect("valid config");
1169
1170 let key = "fast-path-window-advance";
1171 {
1173 let mut buckets = limiter.buckets.write().await;
1174 buckets.insert(
1175 key.to_string(),
1176 CallerBucket {
1177 window_start: AtomicU64::new(1), count: AtomicU64::new(999),
1179 },
1180 );
1181 }
1182
1183 let result = limiter.check(key).await;
1186 assert_eq!(
1187 result.unwrap(),
1188 (),
1189 "fast-path window advance should return Ok(())"
1190 );
1191
1192 assert_eq!(
1193 limiter
1194 .buckets
1195 .read()
1196 .await
1197 .get(key)
1198 .expect("bucket should exist")
1199 .count
1200 .load(Ordering::Relaxed),
1201 1,
1202 "count should be reset to 1 after window advance"
1203 );
1204 }
1205
1206 #[tokio::test]
1212 async fn cleanup_does_not_run_on_first_call() {
1213 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1214 requests_per_window: 10000,
1215 window_secs: 60,
1216 ..RateLimitConfig::default()
1217 })
1218 .expect("valid config");
1219
1220 {
1222 let mut buckets = limiter.buckets.write().await;
1223 buckets.insert(
1224 "stale-first-call".to_string(),
1225 CallerBucket {
1226 window_start: AtomicU64::new(0),
1227 count: AtomicU64::new(1),
1228 },
1229 );
1230 }
1231
1232 let ctx = make_ctx(Some("first-caller"));
1235 assert!(limiter.before(&ctx).await.is_ok());
1236
1237 assert!(
1239 limiter
1240 .buckets
1241 .read()
1242 .await
1243 .contains_key("stale-first-call"),
1244 "stale bucket should not be cleaned up on the very first call"
1245 );
1246 }
1247
1248 #[tokio::test]
1251 async fn x_forwarded_for_single_ip_with_trusted_hop() {
1252 let limiter = RateLimitInterceptor::new(RateLimitConfig {
1253 requests_per_window: 1,
1254 window_secs: 60,
1255 trusted_proxy_hops: 1,
1256 ..RateLimitConfig::default()
1257 })
1258 .expect("valid config");
1259 let mut headers = HashMap::new();
1260 headers.insert("x-forwarded-for".to_string(), "192.168.1.1".to_string());
1261 let ctx = CallContext::new("message/send").with_http_headers(headers);
1262 assert!(limiter.before(&ctx).await.is_ok());
1263 assert!(limiter.before(&ctx).await.is_err());
1265 }
1266}