Skip to main content

featherbit/ratelimit/
mod.rs

1//! Pluggable counter backends for rate-limiting plugins.
2//!
3//! The featherbit analogue of APISIX's `policy: local | redis` counter
4//! abstraction. Rate-limit plugins (limit-count, api-breaker, ...) resolve a
5//! per-client key, then ask a [`CounterStore`] to count it within a fixed
6//! window. `local` (in-memory, per gateway instance) is always available; a
7//! Redis-backed store for cross-instance limits is planned behind a feature
8//! flag and slots in as another [`CounterStore`] implementation.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use async_trait::async_trait;
15use dashmap::DashMap;
16
17/// Outcome of counting one request against a fixed window.
18#[derive(Debug, Clone)]
19pub struct WindowResult {
20    /// Whether this request fits within the limit.
21    pub allowed: bool,
22    /// Requests remaining in the current window (0 when rejected).
23    pub remaining: u64,
24    /// Time until the current window resets.
25    pub reset: Duration,
26    /// The configured limit (echoed for `X-RateLimit-Limit`).
27    pub limit: u64,
28}
29
30/// Counter backend failure (e.g. an unreachable Redis). Plugins decide
31/// whether to fail open (`allow_degradation`) or reject.
32#[derive(Debug)]
33pub struct CounterError(pub String);
34
35impl std::fmt::Display for CounterError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "counter store error: {}", self.0)
38    }
39}
40
41/// A backend that counts requests per key within fixed time windows.
42#[async_trait]
43pub trait CounterStore: Send + Sync {
44    /// Atomically counts one request for `key` in the current fixed window
45    /// of length `window`, allowing up to `limit` requests per window.
46    async fn incr_fixed_window(
47        &self,
48        key: &str,
49        limit: u64,
50        window: Duration,
51    ) -> Result<WindowResult, CounterError>;
52}
53
54/// In-memory fixed-window counters (per gateway instance).
55///
56/// Windows are tracked per key as `(window_start, count)`; a request past
57/// the window's end lazily starts a fresh window. Entries are never actively
58/// expired — stale keys are reset on their next request; memory is bounded
59/// by key cardinality, same as the existing token-bucket plugin.
60#[derive(Default)]
61pub struct LocalCounterStore {
62    windows: DashMap<String, (Instant, u64)>,
63}
64
65#[async_trait]
66impl CounterStore for LocalCounterStore {
67    async fn incr_fixed_window(
68        &self,
69        key: &str,
70        limit: u64,
71        window: Duration,
72    ) -> Result<WindowResult, CounterError> {
73        let now = Instant::now();
74        let mut entry = self
75            .windows
76            .entry(key.to_string())
77            .or_insert_with(|| (now, 0));
78
79        let (start, count) = *entry;
80        let (start, count) = if now.duration_since(start) >= window {
81            (now, 0)
82        } else {
83            (start, count)
84        };
85
86        let allowed = count < limit;
87        let new_count = if allowed { count + 1 } else { count };
88        *entry = (start, new_count);
89
90        let elapsed = now.duration_since(start);
91        Ok(WindowResult {
92            allowed,
93            remaining: limit.saturating_sub(new_count),
94            reset: window.saturating_sub(elapsed),
95            limit,
96        })
97    }
98}
99
100/// Resolves a `policy` config value to a counter backend.
101///
102/// `local` is always available. Unknown policies (including `redis` until
103/// the feature lands) fail at config load with a descriptive error.
104pub struct CounterStoreRegistry {
105    stores: HashMap<String, Arc<dyn CounterStore>>,
106}
107
108impl Default for CounterStoreRegistry {
109    fn default() -> Self {
110        let mut stores: HashMap<String, Arc<dyn CounterStore>> = HashMap::new();
111        stores.insert("local".to_string(), Arc::new(LocalCounterStore::default()));
112        Self { stores }
113    }
114}
115
116impl CounterStoreRegistry {
117    /// Returns the backend for a policy name.
118    pub fn get(&self, policy: &str) -> Result<Arc<dyn CounterStore>, String> {
119        self.stores.get(policy).cloned().ok_or_else(|| {
120            format!("unknown rate-limit policy '{}' — supported: {}", policy, {
121                let mut names: Vec<&str> = self.stores.keys().map(String::as_str).collect();
122                names.sort_unstable();
123                names.join(", ")
124            })
125        })
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[tokio::test]
134    async fn test_fixed_window_counts_and_rejects() {
135        let store = LocalCounterStore::default();
136        let window = Duration::from_secs(60);
137
138        for i in 0..3 {
139            let r = store.incr_fixed_window("k", 3, window).await.unwrap();
140            assert!(r.allowed, "request {} should pass", i);
141            assert_eq!(r.remaining, 2 - i);
142            assert_eq!(r.limit, 3);
143        }
144        let r = store.incr_fixed_window("k", 3, window).await.unwrap();
145        assert!(!r.allowed);
146        assert_eq!(r.remaining, 0);
147        assert!(r.reset <= window);
148
149        // separate keys have separate windows
150        let r = store.incr_fixed_window("other", 3, window).await.unwrap();
151        assert!(r.allowed);
152    }
153
154    #[tokio::test]
155    async fn test_window_resets_after_expiry() {
156        let store = LocalCounterStore::default();
157        let window = Duration::from_millis(30);
158
159        let r = store.incr_fixed_window("k", 1, window).await.unwrap();
160        assert!(r.allowed);
161        let r = store.incr_fixed_window("k", 1, window).await.unwrap();
162        assert!(!r.allowed);
163
164        tokio::time::sleep(Duration::from_millis(40)).await;
165        let r = store.incr_fixed_window("k", 1, window).await.unwrap();
166        assert!(r.allowed, "window should have reset");
167    }
168
169    #[tokio::test]
170    async fn test_registry_lookup() {
171        let registry = CounterStoreRegistry::default();
172        assert!(registry.get("local").is_ok());
173        let err = match registry.get("redis") {
174            Ok(_) => panic!("'redis' should not resolve yet"),
175            Err(e) => e,
176        };
177        assert!(err.contains("unknown rate-limit policy"), "{err}");
178    }
179}