Skip to main content

bamboo_server/
rate_limit.rs

1//! Thin actix-web rate-limiting middleware over the MIT/Apache-2.0-licensed
2//! `governor` crate.
3//!
4//! Replaces `actix-governor` (GPL-3.0-or-later — license-incompatible with
5//! this MIT-licensed crate, #504) with an in-repo `Transform`/`Service` pair
6//! that reproduces exactly the slice of actix-governor 0.10's behavior
7//! `config.rs` relied on:
8//!
9//! - per-key token-bucket throttling, backed by `governor`'s keyed rate
10//!   limiter (same GCRA algorithm, same `Quota::with_period(..).allow_burst(..)`
11//!   construction actix-governor's `GovernorConfigBuilder::finish()` used);
12//! - on throttle: `429 Too Many Requests` with `retry-after` and
13//!   `x-ratelimit-after` headers (seconds until the bucket admits again) and
14//!   Bamboo's canonical nested JSON error envelope;
15//! - on key-extraction failure: whatever response the extractor's error
16//!   produces (bamboo's `ClientIpKeyExtractor` returns `500` with the same
17//!   canonical nested JSON error envelope).
18//!
19//! actix-governor features bamboo never configured — per-method filtering,
20//! `permissive` mode, whitelisted keys, and the `x-ratelimit-limit` /
21//! `-remaining` / `-whitelisted` headers only added by its
22//! `StateInformationMiddleware` — are intentionally NOT reproduced; adding
23//! them back is a small extension to [`KeyExtractor`]/[`RateLimitMiddleware`]
24//! if ever needed, not a rewrite.
25
26use std::future::{ready, Ready};
27use std::num::NonZeroU32;
28use std::rc::Rc;
29use std::sync::Arc;
30use std::time::Duration;
31
32use actix_web::body::{EitherBody, MessageBody};
33use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
34use actix_web::http::StatusCode;
35use actix_web::{Error, HttpResponse, ResponseError};
36use futures::future::LocalBoxFuture;
37use governor::clock::{Clock, DefaultClock};
38use governor::state::keyed::DefaultKeyedStateStore;
39use governor::{Quota, RateLimiter as GovernorRateLimiter};
40
41/// Extracts the rate-limiting key from an incoming request.
42///
43/// Mirrors the one piece of `actix_governor::KeyExtractor`'s shape bamboo
44/// actually used, so `config::ClientIpKeyExtractor` ports over with no
45/// change to its extraction logic — only its error type moves to
46/// [`SimpleKeyExtractionError`] below.
47pub trait KeyExtractor: Clone + 'static {
48    /// The extracted key type — must be usable as a `governor` keyed-limiter key.
49    type Key: Clone + Eq + std::hash::Hash + Send + Sync + 'static;
50    /// The error returned when extraction fails; converts to the response a
51    /// caller sees (via `actix_web::Error`'s blanket `From<ResponseError>`).
52    type KeyExtractionError: ResponseError + 'static;
53
54    /// Extract the key, or fail with a response-producing error.
55    fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError>;
56}
57
58/// A minimal extraction-failure error with Bamboo's canonical JSON body.
59#[derive(Debug, Clone, Copy)]
60pub struct SimpleKeyExtractionError(pub &'static str);
61
62impl SimpleKeyExtractionError {
63    pub const fn new(body: &'static str) -> Self {
64        Self(body)
65    }
66}
67
68impl std::fmt::Display for SimpleKeyExtractionError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "SimpleKeyExtractionError")
71    }
72}
73
74impl std::error::Error for SimpleKeyExtractionError {}
75
76impl ResponseError for SimpleKeyExtractionError {
77    fn status_code(&self) -> StatusCode {
78        StatusCode::INTERNAL_SERVER_ERROR
79    }
80
81    fn error_response(&self) -> HttpResponse {
82        crate::error::json_error(self.status_code(), self.0)
83    }
84}
85
86type KeyedLimiter<Key> = GovernorRateLimiter<Key, DefaultKeyedStateStore<Key>, DefaultClock>;
87
88/// Rate-limiter configuration: a shared quota + key extractor, cheap to
89/// clone (the limiter is behind an `Arc`) so the SAME bucket state is reused
90/// across every `App` factory invocation (actix spins up one `App` per
91/// worker thread) — mirrors `actix_governor::GovernorConfig`.
92pub struct RateLimiterConfig<K: KeyExtractor> {
93    limiter: Arc<KeyedLimiter<K::Key>>,
94    key_extractor: K,
95}
96
97impl<K: KeyExtractor> Clone for RateLimiterConfig<K> {
98    fn clone(&self) -> Self {
99        Self {
100            limiter: self.limiter.clone(),
101            key_extractor: self.key_extractor.clone(),
102        }
103    }
104}
105
106impl<K: KeyExtractor> RateLimiterConfig<K> {
107    /// Build a config allowing bursts up to `burst_size`, replenishing one
108    /// quota element every `period`. Mirrors
109    /// `GovernorConfigBuilder::{milliseconds_per_request,burst_size}.finish()`
110    /// (same `Quota::with_period(..).allow_burst(..)` construction).
111    ///
112    /// Panics if `period` is zero or `burst_size` is zero, same as
113    /// `finish()` returning `None` did — callers clamp beforehand (see
114    /// `config::rate_limiter_config`).
115    pub fn new(period: Duration, burst_size: u32, key_extractor: K) -> Self {
116        let burst = NonZeroU32::new(burst_size).expect("burst_size must be non-zero");
117        let quota = Quota::with_period(period)
118            .expect("period must be non-zero")
119            .allow_burst(burst);
120        Self {
121            limiter: Arc::new(GovernorRateLimiter::keyed(quota)),
122            key_extractor,
123        }
124    }
125}
126
127/// Rate-limiting middleware factory — mirrors `actix_governor::Governor`.
128pub struct RateLimit<K: KeyExtractor> {
129    limiter: Arc<KeyedLimiter<K::Key>>,
130    key_extractor: K,
131}
132
133impl<K: KeyExtractor> RateLimit<K> {
134    /// Create a new middleware factory from a shared [`RateLimiterConfig`].
135    pub fn new(config: &RateLimiterConfig<K>) -> Self {
136        Self {
137            limiter: config.limiter.clone(),
138            key_extractor: config.key_extractor.clone(),
139        }
140    }
141}
142
143impl<S, B, K> Transform<S, ServiceRequest> for RateLimit<K>
144where
145    K: KeyExtractor,
146    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
147    B: MessageBody + 'static,
148{
149    type Response = ServiceResponse<EitherBody<B>>;
150    type Error = Error;
151    type Transform = RateLimitMiddleware<S, K>;
152    type InitError = ();
153    type Future = Ready<Result<Self::Transform, Self::InitError>>;
154
155    fn new_transform(&self, service: S) -> Self::Future {
156        ready(Ok(RateLimitMiddleware {
157            service: Rc::new(service),
158            limiter: self.limiter.clone(),
159            key_extractor: self.key_extractor.clone(),
160        }))
161    }
162}
163
164pub struct RateLimitMiddleware<S, K: KeyExtractor> {
165    service: Rc<S>,
166    limiter: Arc<KeyedLimiter<K::Key>>,
167    key_extractor: K,
168}
169
170impl<S, B, K> Service<ServiceRequest> for RateLimitMiddleware<S, K>
171where
172    K: KeyExtractor,
173    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
174    B: MessageBody + 'static,
175{
176    type Response = ServiceResponse<EitherBody<B>>;
177    type Error = Error;
178    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
179
180    forward_ready!(service);
181
182    fn call(&self, req: ServiceRequest) -> Self::Future {
183        let key = match self.key_extractor.extract(&req) {
184            Ok(key) => key,
185            Err(err) => {
186                // Matches actix-governor's non-permissive extraction-failure
187                // path: fail the request with the extractor's own error
188                // response; the inner service is never reached.
189                return Box::pin(async move { Err(err.into()) });
190            }
191        };
192
193        match self.limiter.check_key(&key) {
194            Ok(_) => {
195                let fut = self.service.call(req);
196                Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) })
197            }
198            Err(negative) => {
199                let wait_time = negative
200                    .wait_time_from(DefaultClock::default().now())
201                    .as_secs();
202                let mut response = crate::error::json_error(
203                    StatusCode::TOO_MANY_REQUESTS,
204                    format!("Too many requests, retry in {wait_time}s"),
205                );
206                response.headers_mut().insert(
207                    actix_web::http::header::RETRY_AFTER,
208                    actix_web::http::header::HeaderValue::from(wait_time),
209                );
210                response.headers_mut().insert(
211                    actix_web::http::header::HeaderName::from_static("x-ratelimit-after"),
212                    actix_web::http::header::HeaderValue::from(wait_time),
213                );
214                let response = req.into_response(response).map_into_right_body();
215                Box::pin(async move { Ok(response) })
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use actix_web::http::StatusCode as HttpStatusCode;
225    use actix_web::{test, web, App, HttpResponse as Resp};
226    use std::net::{IpAddr, Ipv4Addr};
227
228    /// A trivial always-succeeds key extractor keyed on a fixed IP, used to
229    /// unit-test the burst/refill behavior of [`RateLimitMiddleware`]
230    /// directly (independent of `config::ClientIpKeyExtractor`, which has
231    /// its own dedicated tests in `config.rs`).
232    #[derive(Clone)]
233    struct FixedKeyExtractor(IpAddr);
234
235    impl KeyExtractor for FixedKeyExtractor {
236        type Key = IpAddr;
237        type KeyExtractionError = SimpleKeyExtractionError;
238
239        fn extract(&self, _req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
240            Ok(self.0)
241        }
242    }
243
244    #[actix_web::test]
245    async fn burst_then_429_with_retry_after_header() {
246        let key = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 1));
247        let conf = RateLimiterConfig::new(Duration::from_millis(100), 3, FixedKeyExtractor(key));
248        let app = test::init_service(
249            App::new()
250                .wrap(RateLimit::new(&conf))
251                .route("/", web::get().to(|| async { Resp::Ok().finish() })),
252        )
253        .await;
254
255        // The first 3 requests (burst_size) pass.
256        for _ in 0..3 {
257            let res =
258                test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
259            assert_eq!(res.status(), HttpStatusCode::OK);
260        }
261
262        // The 4th is throttled, with a Retry-After header.
263        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
264        assert_eq!(res.status(), HttpStatusCode::TOO_MANY_REQUESTS);
265        assert!(
266            res.headers().contains_key("retry-after"),
267            "a 429 must carry Retry-After"
268        );
269        assert!(
270            res.headers().contains_key("x-ratelimit-after"),
271            "a 429 must carry x-ratelimit-after"
272        );
273        let body: serde_json::Value = test::read_body_json(res).await;
274        assert_eq!(body["error"]["type"], "api_error");
275        assert!(body["error"]["message"]
276            .as_str()
277            .is_some_and(|message| message.starts_with("Too many requests, retry in ")));
278    }
279
280    #[actix_web::test]
281    async fn refills_after_period_elapses() {
282        let key = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2));
283        // burst=1, one element every 20ms — small enough to sleep past in a test.
284        let conf = RateLimiterConfig::new(Duration::from_millis(20), 1, FixedKeyExtractor(key));
285        let app = test::init_service(
286            App::new()
287                .wrap(RateLimit::new(&conf))
288                .route("/", web::get().to(|| async { Resp::Ok().finish() })),
289        )
290        .await;
291
292        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
293        assert_eq!(res.status(), HttpStatusCode::OK);
294
295        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
296        assert_eq!(
297            res.status(),
298            HttpStatusCode::TOO_MANY_REQUESTS,
299            "bucket must be empty immediately after the burst"
300        );
301
302        tokio::time::sleep(Duration::from_millis(60)).await;
303
304        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
305        assert_eq!(
306            res.status(),
307            HttpStatusCode::OK,
308            "the bucket must have refilled after the period elapses"
309        );
310    }
311
312    #[actix_web::test]
313    async fn extraction_failure_returns_extractors_error_response() {
314        #[derive(Clone)]
315        struct AlwaysFailsExtractor;
316
317        impl KeyExtractor for AlwaysFailsExtractor {
318            type Key = ();
319            type KeyExtractionError = SimpleKeyExtractionError;
320
321            fn extract(
322                &self,
323                _req: &ServiceRequest,
324            ) -> Result<Self::Key, Self::KeyExtractionError> {
325                Err(SimpleKeyExtractionError::new("no key for you"))
326            }
327        }
328
329        let conf = RateLimiterConfig::new(Duration::from_secs(1), 1, AlwaysFailsExtractor);
330        let app = test::init_service(
331            App::new()
332                .wrap(RateLimit::new(&conf))
333                .route("/", web::get().to(|| async { Resp::Ok().finish() })),
334        )
335        .await;
336
337        // The middleware returns `Err(err.into())` on extraction failure (matching
338        // actix-governor's non-permissive path) rather than an `Ok(response)` —
339        // real HTTP serving converts that via `ResponseError` further down the
340        // stack (in the H1/H2 dispatcher), so exercise that conversion directly
341        // here via `try_call_service` + `ResponseError::error_response()` instead
342        // of `call_service` (which panics on `Err`, since it bypasses that layer).
343        let err = test::try_call_service(&app, test::TestRequest::get().uri("/").to_request())
344            .await
345            .expect_err("extraction failure must reach the caller as an Err");
346        let response = err.error_response();
347        assert_eq!(response.status(), HttpStatusCode::INTERNAL_SERVER_ERROR);
348        let body = actix_web::body::to_bytes(response.into_body())
349            .await
350            .unwrap();
351        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
352        assert_eq!(body["error"]["message"], "no key for you");
353        assert_eq!(body["error"]["type"], "api_error");
354    }
355}