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