|
1 | | -//! Thread-safe, process-local rate limiting for infrequent runtime events. |
| 1 | +//! Internal call-site rate limiting for infrequent runtime events. |
2 | 2 |
|
3 | 3 | use std::{ |
4 | | - sync::Mutex, |
| 4 | + sync::OnceLock, |
5 | 5 | time::{Duration, Instant}, |
6 | 6 | }; |
7 | 7 |
|
8 | | -/// Allows an operation at most once during each configured interval. |
9 | | -/// |
10 | | -/// A `RateLimiter` is intended to be shared by concurrent runtime tasks. When |
11 | | -/// stored in a `static`, it is initialized once per Lambda execution environment |
12 | | -/// and retains its state across warm invocations. A new cold-started environment |
13 | | -/// receives a new limiter. |
14 | | -pub(crate) struct RateLimiter { |
15 | | - /// Minimum duration between allowed operations. |
16 | | - interval: Duration, |
17 | | - /// Timestamp of the most recent allowed operation. |
18 | | - last_allowed: Mutex<Option<Instant>>, |
19 | | -} |
20 | | - |
21 | | -impl RateLimiter { |
22 | | - /// Creates a rate limiter with the specified minimum interval. |
23 | | - pub(crate) const fn new(interval: Duration) -> RateLimiter { |
24 | | - RateLimiter { |
25 | | - interval, |
26 | | - last_allowed: Mutex::new(None), |
27 | | - } |
28 | | - } |
| 8 | +/// Returns monotonic process-relative time for the rate limiter. |
| 9 | +pub(crate) fn time_since_epoch() -> Duration { |
| 10 | + static EPOCH: OnceLock<Instant> = OnceLock::new(); |
29 | 11 |
|
30 | | - /// Returns the minimum duration between allowed operations. |
31 | | - pub(crate) const fn interval(&self) -> Duration { |
32 | | - self.interval |
33 | | - } |
| 12 | + Instant::now().duration_since(*EPOCH.get_or_init(Instant::now)) |
| 13 | +} |
34 | 14 |
|
35 | | - /// |
36 | | - /// The first call is allowed. Subsequent calls are rejected until the |
37 | | - /// configured interval has elapsed since the previous allowed call. |
38 | | - /// Concurrent callers are serialized while checking and updating the last |
39 | | - /// allowed timestamp so only one caller crosses the interval boundary. |
40 | | - pub(crate) fn allow(&self) -> bool { |
41 | | - let mut last_allowed = match self.last_allowed.lock() { |
42 | | - Ok(guard) => guard, |
43 | | - Err(poisoned) => { |
44 | | - // The limiter state is disposable, so reset it and recover instead of |
45 | | - // allowing a poisoned mutex to crash the runtime or suppress future logs. |
46 | | - let mut guard = poisoned.into_inner(); |
47 | | - *guard = None; |
48 | | - self.last_allowed.clear_poison(); |
49 | | - guard |
| 15 | +/// Evaluates a call at most once per interval at each macro call site. |
| 16 | +/// |
| 17 | +/// The limiter state is local to the call site and persists for the lifetime of |
| 18 | +/// the process. It is therefore shared across warm invocations in one Lambda |
| 19 | +/// execution environment and reset by a cold start. |
| 20 | +macro_rules! rate_limited { |
| 21 | + ($interval:expr, $call:expr) => {{ |
| 22 | + use std::sync::atomic::{AtomicU64, Ordering}; |
| 23 | + |
| 24 | + static NEXT_CALL: AtomicU64 = AtomicU64::new(u64::MIN); |
| 25 | + let interval: std::time::Duration = $interval; |
| 26 | + let time = $crate::rate_limiter::time_since_epoch(); |
| 27 | + let next = NEXT_CALL.load(Ordering::Relaxed); |
| 28 | + |
| 29 | + if next <= time.as_secs() { |
| 30 | + let new_next = time.checked_add(interval).unwrap_or(std::time::Duration::MAX).as_secs(); |
| 31 | + |
| 32 | + if NEXT_CALL |
| 33 | + .compare_exchange(next, new_next, Ordering::Relaxed, Ordering::Relaxed) |
| 34 | + .is_ok() |
| 35 | + { |
| 36 | + $call; |
50 | 37 | } |
51 | | - }; |
52 | | - |
53 | | - if last_allowed |
54 | | - .as_ref() |
55 | | - .is_some_and(|value| value.elapsed() < self.interval) |
56 | | - { |
57 | | - return false; |
58 | 38 | } |
59 | | - |
60 | | - *last_allowed = Some(Instant::now()); |
61 | | - |
62 | | - true |
63 | | - } |
| 39 | + }}; |
64 | 40 | } |
65 | 41 |
|
| 42 | +pub(crate) use rate_limited; |
| 43 | + |
66 | 44 | #[cfg(test)] |
67 | 45 | mod tests { |
68 | 46 | use super::*; |
69 | 47 | use std::{ |
70 | | - panic::{catch_unwind, AssertUnwindSafe}, |
| 48 | + sync::{ |
| 49 | + atomic::{AtomicUsize, Ordering}, |
| 50 | + Arc, |
| 51 | + }, |
71 | 52 | thread, |
72 | 53 | }; |
73 | 54 |
|
74 | 55 | #[test] |
75 | | - fn allows_first_call() { |
76 | | - let limiter = RateLimiter::new(Duration::from_secs(60)); |
77 | | - |
78 | | - assert!(limiter.allow()); |
79 | | - } |
80 | | - |
81 | | - #[test] |
82 | | - fn rejects_calls_inside_interval() { |
83 | | - let limiter = RateLimiter::new(Duration::from_secs(60)); |
84 | | - |
85 | | - assert!(limiter.allow()); |
86 | | - assert!(!limiter.allow()); |
87 | | - } |
88 | | - |
89 | | - #[test] |
90 | | - fn allows_call_after_interval() { |
91 | | - let limiter = RateLimiter::new(Duration::from_millis(10)); |
| 56 | + fn allows_first_call_and_rejects_calls_inside_interval() { |
| 57 | + let mut calls = 0; |
92 | 58 |
|
93 | | - assert!(limiter.allow()); |
94 | | - thread::sleep(Duration::from_millis(15)); |
| 59 | + for _ in 0..2 { |
| 60 | + rate_limited!(Duration::from_secs(60), { |
| 61 | + calls += 1; |
| 62 | + }); |
| 63 | + } |
95 | 64 |
|
96 | | - assert!(limiter.allow()); |
| 65 | + assert_eq!(calls, 1); |
97 | 66 | } |
98 | 67 |
|
99 | 68 | #[test] |
100 | | - fn recovers_from_poisoned_mutex() { |
101 | | - let limiter = RateLimiter::new(Duration::from_secs(60)); |
102 | | - |
103 | | - let _ = catch_unwind(AssertUnwindSafe(|| { |
104 | | - let _guard = limiter.last_allowed.lock().unwrap(); |
105 | | - panic!("poison the limiter mutex"); |
106 | | - })); |
| 69 | + fn allows_only_one_concurrent_call() { |
| 70 | + let calls = Arc::new(AtomicUsize::new(0)); |
| 71 | + let handles = (0..8) |
| 72 | + .map(|_| { |
| 73 | + let calls = Arc::clone(&calls); |
| 74 | + thread::spawn(move || { |
| 75 | + rate_limited!(Duration::from_secs(60), { |
| 76 | + calls.fetch_add(1, Ordering::Relaxed); |
| 77 | + }); |
| 78 | + }) |
| 79 | + }) |
| 80 | + .collect::<Vec<_>>(); |
| 81 | + |
| 82 | + for handle in handles { |
| 83 | + handle.join().unwrap(); |
| 84 | + } |
107 | 85 |
|
108 | | - assert!(limiter.allow()); |
109 | | - assert!(!limiter.allow()); |
| 86 | + assert_eq!(calls.load(Ordering::Relaxed), 1); |
110 | 87 | } |
111 | 88 | } |
0 commit comments