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
79    /// burst capacity of `burst`.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error when `per_second` or `burst` is zero.
84    pub fn new(name: impl Into<String>, per_second: u32, burst: u32) -> AppResult<Self> {
85        let config = RateLimiterConfig::new(name, per_second, burst);
86        Self::from_config(config)
87    }
88
89    /// Create a rate limiter from a configuration object.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error when `config.per_second` or `config.burst` is zero.
94    pub fn from_config(config: RateLimiterConfig) -> AppResult<Self> {
95        let per_sec = non_zero(
96            "per_second",
97            config.per_second,
98            "rate limit must be greater than zero",
99        )?;
100        let burst_size = non_zero(
101            "burst",
102            config.burst,
103            "rate limit burst must be greater than zero",
104        )?;
105        let quota = Quota::per_second(per_sec).allow_burst(burst_size);
106        Ok(Self {
107            inner: Arc::new(GovRateLimiter::direct(quota)),
108            name: config.name,
109        })
110    }
111
112    /// Non-blocking check: returns `Ok(())` if a token was acquired, or
113    /// `Err(AppError::rate_limited())` if the bucket is empty.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`AppError::rate_limited`] when no token is currently available.
118    pub fn check(&self) -> AppResult<()> {
119        self.inner
120            .check()
121            .map_err(|_| AppError::rate_limited().with_detail("rate_limiter", self.name.clone()))
122    }
123
124    /// Async wait: blocks until a token is available or `cancel` fires.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error when the cancellation token fires before a token is
129    /// available.
130    pub async fn until_ready(&self, cancel: Option<CancellationToken>) -> AppResult<()> {
131        match cancel {
132            Some(token) => {
133                tokio::select! {
134                    _ = self.inner.until_ready() => Ok(()),
135                    _ = token.cancelled() => {
136                        Err(AppError::service_unavailable("rate limiter cancelled"))
137                    }
138                }
139            }
140            None => {
141                self.inner.until_ready().await;
142                Ok(())
143            }
144        }
145    }
146}
147
148fn non_zero(field: &'static str, value: u32, message: &'static str) -> AppResult<NonZeroU32> {
149    NonZeroU32::new(value).ok_or_else(|| AppError::invalid_input(field, message))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[tokio::test]
157    async fn check_allows_up_to_burst_limit() {
158        let rl = RateLimiter::new("test", 1, 5).unwrap();
159        for _ in 0..5 {
160            assert!(rl.check().is_ok());
161        }
162    }
163
164    #[tokio::test]
165    async fn check_rejects_when_bucket_exhausted() {
166        let rl = RateLimiter::new("test", 1, 3).unwrap();
167        for _ in 0..3 {
168            let _ = rl.check();
169        }
170        let result = rl.check();
171        assert!(result.is_err());
172    }
173
174    #[tokio::test]
175    async fn check_returns_rate_limited_error_code() {
176        use rskit_errors::ErrorCode;
177        let rl = RateLimiter::new("test", 1, 1).unwrap();
178        let _ = rl.check();
179        let err = rl.check().unwrap_err();
180        assert_eq!(err.code(), ErrorCode::RateLimited);
181    }
182
183    #[tokio::test]
184    async fn until_ready_cancels_when_token_cancelled() {
185        let rl = RateLimiter::new("test", 1, 1).unwrap();
186        let _ = rl.check();
187
188        let cancel = CancellationToken::new();
189        let cancel_clone = cancel.clone();
190        cancel_clone.cancel();
191
192        let result = rl.until_ready(Some(cancel)).await;
193        assert!(result.is_err());
194    }
195
196    #[test]
197    fn from_config_builds_rate_limiter() {
198        let limiter = RateLimiter::from_config(RateLimiterConfig::new("cfg", 10, 2)).unwrap();
199        assert!(limiter.check().is_ok());
200    }
201
202    #[test]
203    fn from_config_rejects_zero_limits() {
204        assert!(RateLimiter::from_config(RateLimiterConfig::new("zero-rate", 0, 1)).is_err());
205        assert!(RateLimiter::from_config(RateLimiterConfig::new("zero-burst", 1, 0)).is_err());
206    }
207}