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//!   a `text/plain` body — byte-for-byte what actix-governor's `NoOpMiddleware`
15//!   path produced;
16//! - on key-extraction failure: whatever response the extractor's error
17//!   produces (bamboo's `ClientIpKeyExtractor` mirrors actix-governor's
18//!   `SimpleKeyExtractionError` default: `500`, `text/plain`).
19//!
20//! actix-governor features bamboo never configured — per-method filtering,
21//! `permissive` mode, whitelisted keys, and the `x-ratelimit-limit` /
22//! `-remaining` / `-whitelisted` headers only added by its
23//! `StateInformationMiddleware` — are intentionally NOT reproduced; adding
24//! them back is a small extension to [`KeyExtractor`]/[`RateLimitMiddleware`]
25//! if ever needed, not a rewrite.
26
27use std::future::{ready, Ready};
28use std::num::NonZeroU32;
29use std::rc::Rc;
30use std::sync::Arc;
31use std::time::Duration;
32
33use actix_web::body::{EitherBody, MessageBody};
34use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
35use actix_web::http::StatusCode;
36use actix_web::{Error, HttpResponse, HttpResponseBuilder, ResponseError};
37use futures::future::LocalBoxFuture;
38use governor::clock::{Clock, DefaultClock};
39use governor::state::keyed::DefaultKeyedStateStore;
40use governor::{Quota, RateLimiter as GovernorRateLimiter};
41
42/// Extracts the rate-limiting key from an incoming request.
43///
44/// Mirrors the one piece of `actix_governor::KeyExtractor`'s shape bamboo
45/// actually used, so `config::ClientIpKeyExtractor` ports over with no
46/// change to its extraction logic — only its error type moves to
47/// [`SimpleKeyExtractionError`] below.
48pub trait KeyExtractor: Clone + 'static {
49    /// The extracted key type — must be usable as a `governor` keyed-limiter key.
50    type Key: Clone + Eq + std::hash::Hash + Send + Sync + 'static;
51    /// The error returned when extraction fails; converts to the response a
52    /// caller sees (via `actix_web::Error`'s blanket `From<ResponseError>`).
53    type KeyExtractionError: ResponseError + 'static;
54
55    /// Extract the key, or fail with a response-producing error.
56    fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError>;
57}
58
59/// A minimal extraction-failure error: fixed status + `text/plain` body.
60///
61/// Matches `actix_governor::SimpleKeyExtractionError`'s default (status
62/// `500`, content-type `text/plain`) — the only shape bamboo ever used (it
63/// never called that type's `set_status_code`/`set_content_type`).
64#[derive(Debug, Clone, Copy)]
65pub struct SimpleKeyExtractionError(pub &'static str);
66
67impl SimpleKeyExtractionError {
68    pub const fn new(body: &'static str) -> Self {
69        Self(body)
70    }
71}
72
73impl std::fmt::Display for SimpleKeyExtractionError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        write!(f, "SimpleKeyExtractionError")
76    }
77}
78
79impl std::error::Error for SimpleKeyExtractionError {}
80
81impl ResponseError for SimpleKeyExtractionError {
82    fn status_code(&self) -> StatusCode {
83        StatusCode::INTERNAL_SERVER_ERROR
84    }
85
86    fn error_response(&self) -> HttpResponse {
87        HttpResponseBuilder::new(self.status_code())
88            .content_type("text/plain; charset=utf-8")
89            .body(self.0)
90    }
91}
92
93type KeyedLimiter<Key> = GovernorRateLimiter<Key, DefaultKeyedStateStore<Key>, DefaultClock>;
94
95/// Rate-limiter configuration: a shared quota + key extractor, cheap to
96/// clone (the limiter is behind an `Arc`) so the SAME bucket state is reused
97/// across every `App` factory invocation (actix spins up one `App` per
98/// worker thread) — mirrors `actix_governor::GovernorConfig`.
99pub struct RateLimiterConfig<K: KeyExtractor> {
100    limiter: Arc<KeyedLimiter<K::Key>>,
101    key_extractor: K,
102}
103
104impl<K: KeyExtractor> Clone for RateLimiterConfig<K> {
105    fn clone(&self) -> Self {
106        Self {
107            limiter: self.limiter.clone(),
108            key_extractor: self.key_extractor.clone(),
109        }
110    }
111}
112
113impl<K: KeyExtractor> RateLimiterConfig<K> {
114    /// Build a config allowing bursts up to `burst_size`, replenishing one
115    /// quota element every `period`. Mirrors
116    /// `GovernorConfigBuilder::{milliseconds_per_request,burst_size}.finish()`
117    /// (same `Quota::with_period(..).allow_burst(..)` construction).
118    ///
119    /// Panics if `period` is zero or `burst_size` is zero, same as
120    /// `finish()` returning `None` did — callers clamp beforehand (see
121    /// `config::rate_limiter_config`).
122    pub fn new(period: Duration, burst_size: u32, key_extractor: K) -> Self {
123        let burst = NonZeroU32::new(burst_size).expect("burst_size must be non-zero");
124        let quota = Quota::with_period(period)
125            .expect("period must be non-zero")
126            .allow_burst(burst);
127        Self {
128            limiter: Arc::new(GovernorRateLimiter::keyed(quota)),
129            key_extractor,
130        }
131    }
132}
133
134/// Rate-limiting middleware factory — mirrors `actix_governor::Governor`.
135pub struct RateLimit<K: KeyExtractor> {
136    limiter: Arc<KeyedLimiter<K::Key>>,
137    key_extractor: K,
138}
139
140impl<K: KeyExtractor> RateLimit<K> {
141    /// Create a new middleware factory from a shared [`RateLimiterConfig`].
142    pub fn new(config: &RateLimiterConfig<K>) -> Self {
143        Self {
144            limiter: config.limiter.clone(),
145            key_extractor: config.key_extractor.clone(),
146        }
147    }
148}
149
150impl<S, B, K> Transform<S, ServiceRequest> for RateLimit<K>
151where
152    K: KeyExtractor,
153    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
154    B: MessageBody + 'static,
155{
156    type Response = ServiceResponse<EitherBody<B>>;
157    type Error = Error;
158    type Transform = RateLimitMiddleware<S, K>;
159    type InitError = ();
160    type Future = Ready<Result<Self::Transform, Self::InitError>>;
161
162    fn new_transform(&self, service: S) -> Self::Future {
163        ready(Ok(RateLimitMiddleware {
164            service: Rc::new(service),
165            limiter: self.limiter.clone(),
166            key_extractor: self.key_extractor.clone(),
167        }))
168    }
169}
170
171pub struct RateLimitMiddleware<S, K: KeyExtractor> {
172    service: Rc<S>,
173    limiter: Arc<KeyedLimiter<K::Key>>,
174    key_extractor: K,
175}
176
177impl<S, B, K> Service<ServiceRequest> for RateLimitMiddleware<S, K>
178where
179    K: KeyExtractor,
180    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
181    B: MessageBody + 'static,
182{
183    type Response = ServiceResponse<EitherBody<B>>;
184    type Error = Error;
185    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
186
187    forward_ready!(service);
188
189    fn call(&self, req: ServiceRequest) -> Self::Future {
190        let key = match self.key_extractor.extract(&req) {
191            Ok(key) => key,
192            Err(err) => {
193                // Matches actix-governor's non-permissive extraction-failure
194                // path: fail the request with the extractor's own error
195                // response; the inner service is never reached.
196                return Box::pin(async move { Err(err.into()) });
197            }
198        };
199
200        match self.limiter.check_key(&key) {
201            Ok(_) => {
202                let fut = self.service.call(req);
203                Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) })
204            }
205            Err(negative) => {
206                let wait_time = negative
207                    .wait_time_from(DefaultClock::default().now())
208                    .as_secs();
209                let response = HttpResponse::build(StatusCode::TOO_MANY_REQUESTS)
210                    .insert_header(("retry-after", wait_time))
211                    .insert_header(("x-ratelimit-after", wait_time))
212                    .content_type("text/plain; charset=utf-8")
213                    .body(format!("Too many requests, retry in {wait_time}s"));
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    }
274
275    #[actix_web::test]
276    async fn refills_after_period_elapses() {
277        let key = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2));
278        // burst=1, one element every 20ms — small enough to sleep past in a test.
279        let conf = RateLimiterConfig::new(Duration::from_millis(20), 1, FixedKeyExtractor(key));
280        let app = test::init_service(
281            App::new()
282                .wrap(RateLimit::new(&conf))
283                .route("/", web::get().to(|| async { Resp::Ok().finish() })),
284        )
285        .await;
286
287        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
288        assert_eq!(res.status(), HttpStatusCode::OK);
289
290        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
291        assert_eq!(
292            res.status(),
293            HttpStatusCode::TOO_MANY_REQUESTS,
294            "bucket must be empty immediately after the burst"
295        );
296
297        tokio::time::sleep(Duration::from_millis(60)).await;
298
299        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
300        assert_eq!(
301            res.status(),
302            HttpStatusCode::OK,
303            "the bucket must have refilled after the period elapses"
304        );
305    }
306
307    #[actix_web::test]
308    async fn extraction_failure_returns_extractors_error_response() {
309        #[derive(Clone)]
310        struct AlwaysFailsExtractor;
311
312        impl KeyExtractor for AlwaysFailsExtractor {
313            type Key = ();
314            type KeyExtractionError = SimpleKeyExtractionError;
315
316            fn extract(
317                &self,
318                _req: &ServiceRequest,
319            ) -> Result<Self::Key, Self::KeyExtractionError> {
320                Err(SimpleKeyExtractionError::new("no key for you"))
321            }
322        }
323
324        let conf = RateLimiterConfig::new(Duration::from_secs(1), 1, AlwaysFailsExtractor);
325        let app = test::init_service(
326            App::new()
327                .wrap(RateLimit::new(&conf))
328                .route("/", web::get().to(|| async { Resp::Ok().finish() })),
329        )
330        .await;
331
332        // The middleware returns `Err(err.into())` on extraction failure (matching
333        // actix-governor's non-permissive path) rather than an `Ok(response)` —
334        // real HTTP serving converts that via `ResponseError` further down the
335        // stack (in the H1/H2 dispatcher), so exercise that conversion directly
336        // here via `try_call_service` + `ResponseError::error_response()` instead
337        // of `call_service` (which panics on `Err`, since it bypasses that layer).
338        let err = test::try_call_service(&app, test::TestRequest::get().uri("/").to_request())
339            .await
340            .expect_err("extraction failure must reach the caller as an Err");
341        let response = err.error_response();
342        assert_eq!(response.status(), HttpStatusCode::INTERNAL_SERVER_ERROR);
343    }
344}