Skip to main content

rskit_resilience/
rate_limiter.rs

1use std::num::NonZeroU32;
2use std::sync::Arc;
3
4use governor::{DefaultDirectRateLimiter, Quota, RateLimiter as GovRateLimiter};
5use rskit_errors::{AppError, AppResult};
6use tokio_util::sync::CancellationToken;
7
8/// Configuration for constructing a [`RateLimiter`].
9#[derive(Debug, Clone)]
10pub struct RateLimiterConfig {
11    /// Human-readable limiter name used in error details.
12    pub name: String,
13    /// Requests permitted per second.
14    pub per_second: u32,
15    /// Burst capacity.
16    pub burst: u32,
17}
18
19impl RateLimiterConfig {
20    /// Create a new rate-limiter configuration.
21    #[must_use]
22    pub fn new(name: impl Into<String>, per_second: u32, burst: u32) -> Self {
23        Self {
24            name: name.into(),
25            per_second,
26            burst,
27        }
28    }
29
30    /// Set the steady-state requests per second.
31    #[must_use]
32    pub fn with_per_second(mut self, per_second: u32) -> Self {
33        self.per_second = per_second;
34        self
35    }
36
37    /// Set the burst capacity.
38    #[must_use]
39    pub fn with_burst(mut self, burst: u32) -> Self {
40        self.burst = burst;
41        self
42    }
43
44    /// Validate that the token bucket is bounded and non-zero.
45    pub fn validate(&self) -> AppResult<()> {
46        if self.per_second == 0 {
47            return Err(AppError::invalid_input(
48                "per_second",
49                "rate limit must be greater than zero",
50            ));
51        }
52        if self.burst == 0 {
53            return Err(AppError::invalid_input(
54                "burst",
55                "rate limit burst must be greater than zero",
56            ));
57        }
58        Ok(())
59    }
60}
61
62/// Token-bucket rate limiter backed by `governor`.
63#[derive(Clone)]
64pub struct RateLimiter {
65    inner: Arc<DefaultDirectRateLimiter>,
66    name: String,
67}
68
69impl std::fmt::Debug for RateLimiter {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("RateLimiter")
72            .field("name", &self.name)
73            .finish()
74    }
75}
76
77impl RateLimiter {
78    /// Create a rate limiter that allows `per_second` requests/second with a burst capacity of `burst`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error when `per_second` or `burst` is zero.
83    pub fn new(name: impl Into<String>, per_second: u32, burst: u32) -> AppResult<Self> {
84        let config = RateLimiterConfig::new(name, per_second, burst);
85        Self::from_config(config)
86    }
87
88    /// Create a rate limiter from a configuration object.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error when `config.per_second` or `config.burst` is zero.
93    pub fn from_config(config: RateLimiterConfig) -> AppResult<Self> {
94        let per_sec = non_zero(
95            "per_second",
96            config.per_second,
97            "rate limit must be greater than zero",
98        )?;
99        let burst_size = non_zero(
100            "burst",
101            config.burst,
102            "rate limit burst must be greater than zero",
103        )?;
104        let quota = Quota::per_second(per_sec).allow_burst(burst_size);
105        Ok(Self {
106            inner: Arc::new(GovRateLimiter::direct(quota)),
107            name: config.name,
108        })
109    }
110
111    /// Non-blocking check: returns `Ok(())` if a token was acquired,
112    /// or `Err(AppError::rate_limited())` if the bucket is empty.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`AppError::rate_limited`] when no token is currently available.
117    pub fn check(&self) -> AppResult<()> {
118        self.inner
119            .check()
120            .map_err(|_| AppError::rate_limited().with_detail("rate_limiter", self.name.clone()))
121    }
122
123    /// Async wait: blocks until a token is available or `cancel` fires.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error when the cancellation token fires before a token is available.
128    pub async fn until_ready(&self, cancel: Option<CancellationToken>) -> AppResult<()> {
129        match cancel {
130            Some(token) => {
131                tokio::select! {
132                    _ = self.inner.until_ready() => Ok(()),
133                    _ = token.cancelled() => {
134                        Err(AppError::service_unavailable("rate limiter cancelled"))
135                    }
136                }
137            }
138            None => {
139                self.inner.until_ready().await;
140                Ok(())
141            }
142        }
143    }
144}
145
146fn non_zero(field: &'static str, value: u32, message: &'static str) -> AppResult<NonZeroU32> {
147    NonZeroU32::new(value).ok_or_else(|| AppError::invalid_input(field, message))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[tokio::test]
155    async fn check_allows_up_to_burst_limit() {
156        let rl = RateLimiter::new("test", 1, 5).unwrap();
157        for _ in 0..5 {
158            assert!(rl.check().is_ok());
159        }
160    }
161
162    #[tokio::test]
163    async fn check_rejects_when_bucket_exhausted() {
164        let rl = RateLimiter::new("test", 1, 3).unwrap();
165        for _ in 0..3 {
166            let _ = rl.check();
167        }
168        let result = rl.check();
169        assert!(result.is_err());
170    }
171
172    #[tokio::test]
173    async fn check_returns_rate_limited_error_code() {
174        use rskit_errors::ErrorCode;
175        let rl = RateLimiter::new("test", 1, 1).unwrap();
176        let _ = rl.check();
177        let err = rl.check().unwrap_err();
178        assert_eq!(err.code(), ErrorCode::RateLimited);
179    }
180
181    #[tokio::test]
182    async fn until_ready_cancels_when_token_cancelled() {
183        let rl = RateLimiter::new("test", 1, 1).unwrap();
184        let _ = rl.check();
185
186        let cancel = CancellationToken::new();
187        let cancel_clone = cancel.clone();
188        cancel_clone.cancel();
189
190        let result = rl.until_ready(Some(cancel)).await;
191        assert!(result.is_err());
192    }
193
194    #[test]
195    fn from_config_builds_rate_limiter() {
196        let limiter = RateLimiter::from_config(RateLimiterConfig::new("cfg", 10, 2)).unwrap();
197        assert!(limiter.check().is_ok());
198    }
199
200    #[test]
201    fn from_config_rejects_zero_limits() {
202        assert!(RateLimiter::from_config(RateLimiterConfig::new("zero-rate", 0, 1)).is_err());
203        assert!(RateLimiter::from_config(RateLimiterConfig::new("zero-burst", 1, 0)).is_err());
204    }
205}