Skip to main content

lc_a2a/
rate_limiter.rs

1//! Rate limiting for A2A requests.
2//!
3//! `RateLimiter` combines two independent limits:
4//!
5//! - **Concurrency limit**: a `tokio::sync::Semaphore` bounding how many
6//!   requests are in-flight at once.
7//! - **Window rate limit**: a rolling one-minute token count bounding how
8//!   many requests may be admitted per window.
9//!
10//! Acquiring a permit returns a `RateLimitPermit` guard that releases the
11//! concurrency slot when dropped. Pass `0` for either dimension to disable
12//! that limit.
13
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
18
19/// Error returned when a request is not admitted by the rate limiter.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum RateLimitError {
23    /// The per-window request budget has been exhausted.
24    #[error("request rate limit exceeded")]
25    TooManyRequests,
26    /// The maximum number of concurrent requests is already in flight.
27    #[error("too many concurrent requests")]
28    ConcurrencyLimitExceeded,
29}
30
31struct WindowState {
32    window_start: Instant,
33    count: usize,
34}
35
36/// A rate limiter combining a concurrency cap and a per-minute request cap.
37pub struct RateLimiter {
38    /// Semaphore guarding the maximum number of concurrent requests.
39    semaphore: Arc<Semaphore>,
40    /// Length of the rate window.
41    window: Duration,
42    /// Maximum requests admitted per window (`0` = unlimited).
43    max_requests: usize,
44    /// Rolling window counter.
45    state: Mutex<WindowState>,
46}
47
48impl RateLimiter {
49    /// Create a new rate limiter.
50    ///
51    /// `max_concurrent` bounds in-flight requests (0 = unlimited) and
52    /// `max_requests_per_minute` bounds the per-minute admission rate
53    /// (0 = unlimited).
54    pub fn new(max_concurrent: usize, max_requests_per_minute: usize) -> Self {
55        let permits = if max_concurrent == 0 {
56            Semaphore::MAX_PERMITS
57        } else {
58            max_concurrent
59        };
60        Self {
61            semaphore: Arc::new(Semaphore::new(permits)),
62            window: Duration::from_secs(60),
63            max_requests: max_requests_per_minute,
64            state: Mutex::new(WindowState {
65                window_start: Instant::now(),
66                count: 0,
67            }),
68        }
69    }
70
71    /// Try to acquire a permit to process one request.
72    ///
73    /// Returns a guard that releases the concurrency slot when dropped, or a
74    /// `RateLimitError` if the request would exceed either limit.
75    pub async fn try_acquire(&self) -> Result<RateLimitPermit, RateLimitError> {
76        // 0.22.0 audit fix (H-P3): the window check, concurrency admission and
77        // counter increment share one synchronous critical section
78        // (`try_acquire_owned` never awaits), so a request rejected by the
79        // concurrency cap no longer consumes the per-window budget.
80        let mut state = self.state.lock().await;
81        let now = Instant::now();
82        if now.duration_since(state.window_start) >= self.window {
83            state.window_start = now;
84            state.count = 0;
85        }
86        if self.max_requests > 0 && state.count >= self.max_requests {
87            return Err(RateLimitError::TooManyRequests);
88        }
89
90        // Concurrency slot.
91        let permit = self
92            .semaphore
93            .clone()
94            .try_acquire_owned()
95            .map_err(|_| RateLimitError::ConcurrencyLimitExceeded)?;
96
97        if self.max_requests > 0 {
98            state.count += 1;
99        }
100        drop(state);
101
102        Ok(RateLimitPermit { _permit: permit })
103    }
104}
105
106/// Guard that releases a rate-limiter concurrency slot when dropped.
107pub struct RateLimitPermit {
108    _permit: OwnedSemaphorePermit,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn unlimited_acquires() {
117        let limiter = RateLimiter::new(0, 0);
118        let rt = tokio::runtime::Runtime::new().unwrap();
119        rt.block_on(async {
120            let permit = limiter.try_acquire().await;
121            assert!(permit.is_ok());
122            drop(permit);
123            assert!(limiter.try_acquire().await.is_ok());
124        });
125    }
126
127    #[test]
128    fn enforces_per_minute_limit() {
129        let limiter = RateLimiter::new(0, 2);
130        let rt = tokio::runtime::Runtime::new().unwrap();
131        rt.block_on(async {
132            assert!(limiter.try_acquire().await.is_ok());
133            assert!(limiter.try_acquire().await.is_ok());
134            let err = limiter.try_acquire().await;
135            assert!(matches!(err, Err(RateLimitError::TooManyRequests)));
136        });
137    }
138
139    #[test]
140    fn enforces_concurrency_limit() {
141        let limiter = RateLimiter::new(1, 0);
142        let rt = tokio::runtime::Runtime::new().unwrap();
143        rt.block_on(async {
144            let p1 = limiter.try_acquire().await.unwrap();
145            let err = limiter.try_acquire().await;
146            assert!(matches!(err, Err(RateLimitError::ConcurrencyLimitExceeded)));
147            drop(p1);
148            // Slot freed -> acquire succeeds again.
149            assert!(limiter.try_acquire().await.is_ok());
150        });
151    }
152
153    // 0.22.0 audit fix (H-P3): the permit must keep the concurrency slot
154    // reserved while the caller holds it across the dispatch await.
155    #[tokio::test]
156    async fn permit_is_held_across_await() {
157        let limiter = Arc::new(RateLimiter::new(1, 0));
158        let permit = limiter.try_acquire().await.unwrap();
159        let other = limiter.clone();
160        let rejected = tokio::spawn(async move { other.try_acquire().await.is_err() });
161        tokio::time::sleep(Duration::from_millis(20)).await;
162        assert!(
163            rejected.await.unwrap(),
164            "concurrency cap must apply while the permit is held across an await"
165        );
166        drop(permit);
167        assert!(limiter.try_acquire().await.is_ok());
168    }
169
170    #[tokio::test]
171    async fn concurrency_rejection_does_not_consume_window_budget() {
172        let limiter = RateLimiter::new(1, 2);
173        let permit = limiter.try_acquire().await.unwrap();
174        // Rejected by the concurrency cap — must not burn a window slot.
175        assert!(matches!(
176            limiter.try_acquire().await,
177            Err(RateLimitError::ConcurrencyLimitExceeded)
178        ));
179        drop(permit);
180        assert!(limiter.try_acquire().await.is_ok());
181        // Only two window slots were ever consumed: the legit acquire plus
182        // the one after the rejection.
183        assert!(matches!(
184            limiter.try_acquire().await,
185            Err(RateLimitError::TooManyRequests)
186        ));
187    }
188}