aster_forge_middleware/shared/
rate_limit.rs1use governor::clock::{Clock, DefaultClock, QuantaInstant};
3use governor::middleware::NoOpMiddleware;
4use governor::state::keyed::DefaultKeyedStateStore;
5use governor::{NotUntil, Quota, RateLimiter};
6use std::num::{NonZeroU32, NonZeroU64};
7use std::sync::Arc;
8use std::time::Duration;
9type StringKeyedLimiter =
10 RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock, NoOpMiddleware>;
11#[must_use]
16pub fn retry_after_seconds(not_until: &NotUntil<QuantaInstant>) -> u64 {
17 let delay = not_until.wait_time_from(DefaultClock::default().now());
18 ceil_retry_after(delay)
19}
20
21fn ceil_retry_after(delay: Duration) -> u64 {
22 delay
23 .as_secs()
24 .saturating_add(u64::from(delay.subsec_nanos() > 0))
25 .max(1)
26}
27
28#[derive(Clone)]
35pub struct NormalizedStringRateLimiter {
36 enabled: bool,
37 limiter: Arc<StringKeyedLimiter>,
38}
39
40impl NormalizedStringRateLimiter {
41 #[must_use]
43 pub const fn enabled(&self) -> bool {
44 self.enabled
45 }
46
47 pub fn retain_recent(&self) {
49 self.limiter.retain_recent();
50 }
51
52 #[must_use]
54 pub fn new(enabled: bool, seconds_per_request: NonZeroU64, burst_size: NonZeroU32) -> Self {
55 Self {
56 enabled,
57 limiter: Arc::new(RateLimiter::keyed(rate_limit_quota(
58 seconds_per_request,
59 burst_size,
60 ))),
61 }
62 }
63
64 #[must_use]
66 pub fn check(&self, raw_key: &str) -> Option<RateLimitRejection> {
67 if !self.enabled {
68 return None;
69 }
70
71 let key = raw_key.trim().to_ascii_lowercase();
72 self.limiter
73 .check_key(&key)
74 .err()
75 .map(|not_until| RateLimitRejection::from_not_until(¬_until))
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct RateLimitRejection {
82 retry_after_seconds: u64,
83}
84
85impl RateLimitRejection {
86 pub(crate) fn from_not_until(not_until: &NotUntil<QuantaInstant>) -> Self {
87 Self {
88 retry_after_seconds: retry_after_seconds(not_until),
89 }
90 }
91
92 #[must_use]
94 pub const fn retry_after_seconds(self) -> u64 {
95 self.retry_after_seconds
96 }
97}
98
99#[expect(clippy::expect_used, reason = "NonZeroU64 creates a non-zero duration")]
104#[must_use]
105pub fn rate_limit_quota(seconds_per_request: NonZeroU64, burst_size: NonZeroU32) -> Quota {
106 Quota::with_period(Duration::from_secs(seconds_per_request.get()))
107 .expect("non-zero period")
108 .allow_burst(burst_size)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::num::{NonZeroU32, NonZeroU64};
115 #[test]
116 fn quota_refills_at_the_exact_boundary_without_sleep() {
117 use governor::clock::FakeRelativeClock;
118 let clock = FakeRelativeClock::default();
119 let quota = rate_limit_quota(NonZeroU64::new(2).unwrap(), NonZeroU32::new(2).unwrap());
120 let limiter = RateLimiter::dashmap_with_clock(quota, clock.clone());
121 assert!(limiter.check_key(&"one").is_ok());
122 assert!(limiter.check_key(&"one").is_ok());
123 assert!(limiter.check_key(&"one").is_err());
124 assert!(limiter.check_key(&"two").is_ok());
125 clock.advance(Duration::from_millis(1999));
126 assert!(limiter.check_key(&"one").is_err());
127 clock.advance(Duration::from_millis(1));
128 assert!(limiter.check_key(&"one").is_ok());
129 assert!(limiter.check_key(&"one").is_err());
130 }
131
132 #[test]
133 fn multi_second_retry_delay_rounds_up_instead_of_inviting_early_retry() {
134 assert_eq!(ceil_retry_after(Duration::from_millis(1999)), 2);
135 assert_eq!(ceil_retry_after(Duration::from_millis(59_999)), 60);
136 assert_eq!(ceil_retry_after(Duration::from_secs(2)), 2);
137 assert_eq!(ceil_retry_after(Duration::ZERO), 1);
138 }
139 #[test]
140 fn retry_after_seconds_rounds_sub_second_waits_up_to_one() {
141 let quota = governor::Quota::with_period(std::time::Duration::from_secs(1))
142 .unwrap()
143 .allow_burst(NonZeroU32::new(1).unwrap());
144 let limiter = governor::RateLimiter::keyed(quota);
145
146 assert!(limiter.check_key(&"key").is_ok());
147 let not_until = limiter
148 .check_key(&"key")
149 .expect_err("second immediate check should be rate limited");
150
151 assert_eq!(retry_after_seconds(¬_until), 1);
155 }
156 #[test]
157 fn normalized_string_limiter_can_be_disabled() {
158 let limiter = NormalizedStringRateLimiter::new(
159 false,
160 NonZeroU64::new(60).unwrap(),
161 NonZeroU32::new(1).unwrap(),
162 );
163
164 assert!(limiter.check("admin@example.com").is_none());
165 assert!(limiter.check("admin@example.com").is_none());
166 }
167 #[test]
168 fn normalized_string_limiter_trims_and_lowercases_keys() {
169 let limiter = NormalizedStringRateLimiter::new(
170 true,
171 NonZeroU64::new(60).unwrap(),
172 NonZeroU32::new(1).unwrap(),
173 );
174
175 assert!(limiter.check("Admin@Example.com").is_none());
176 assert!(limiter.check("other@example.com").is_none());
177 assert!(limiter.check(" admin@example.com ").is_some());
178 }
179}