Skip to main content

featherbit/plugins/native/
limit_count.rs

1//! Fixed-window request-count limiting plugin (`limit-count`).
2//!
3//! The featherbit port of Apache APISIX's `limit-count`: counts requests per
4//! resolved key within a fixed time window using a shared [`CounterStore`]
5//! backend (see [`crate::ratelimit`]). Requests that exceed `count` within
6//! `time_window` seconds are rejected through the node's `limited` port with
7//! the configured status. Unlike the token-bucket `rate-limit` plugin (smooth
8//! refill), this enforces a hard cap per discrete window, matching APISIX
9//! semantics. A counter-backend failure is a genuine infrastructure failure
10//! and stays on `error`.
11
12use async_trait::async_trait;
13use bytes::Bytes;
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::Duration;
17
18use crate::context::{Context, GatewayError};
19use crate::plugins::resources::PluginResources;
20use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
21use crate::ratelimit::CounterStore;
22use crate::vars::template::Template;
23
24/// Enforces a per-key request count within a fixed time window.
25///
26/// On each request the resolved key is counted against a [`CounterStore`]:
27/// the in-memory `local` backend, or a named `stores:` entry via
28/// `policy: redis` for cluster-shared counters. Within the limit the
29/// request passes through the `success` port; over the limit it is rejected
30/// with `rejected_code` and a JSON `{"error_msg": ...}` body through the
31/// `limited` port. A counter-backend failure stays on `error`. When
32/// `show_limit_quota_header` is set the `X-RateLimit-Limit`/`-Remaining`/
33/// `-Reset` headers are written onto `context.response` in both cases.
34pub struct LimitCountPlugin {
35    /// Maximum number of requests allowed per window.
36    count: u64,
37    /// Length of the fixed window.
38    window: Duration,
39    /// Key template: supports `{{namespace.path}}` references and legacy
40    /// `$var` interpolation (see [`Template::render_with_legacy`]); empty
41    /// result falls back to the client remote address.
42    key_template: Template,
43    /// Optional counter-key prefix so multiple nodes share one counter.
44    group: Option<String>,
45    /// Status returned when a request is rejected.
46    rejected_code: u16,
47    /// Optional custom message used in the rejection body. Supports
48    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
49    /// this field never supported it, so this sweep must not start).
50    rejected_msg: Option<Template>,
51    /// Whether to emit the `X-RateLimit-*` quota headers.
52    show_limit_quota_header: bool,
53    /// When true, a counter-backend error lets the request through instead of
54    /// failing it.
55    allow_degradation: bool,
56    /// Resolved counter backend (from `policy`).
57    store: Arc<dyn CounterStore>,
58}
59
60impl LimitCountPlugin {
61    /// Builds the plugin from node config.
62    ///
63    /// Accepted keys:
64    /// - `count` (integer > 0, **required**): requests allowed per window.
65    /// - `time_window` (integer > 0, **required**): window length in seconds.
66    /// - `key` (string, default `"$remote_addr"`): a template resolved per
67    ///   request — supports `{{namespace.path}}` references plus legacy
68    ///   `$var` interpolation (e.g. `$remote_addr`, `$consumer_name`,
69    ///   `$http_x_api_key`). An empty resolved value falls back to the client
70    ///   remote address.
71    /// - `policy` (string, default `"local"`): counter backend. `local` is
72    ///   always available; `redis` resolves `store:` to a named `stores:`
73    ///   entry for cluster-shared counters (requires `store:`; any other
74    ///   value is rejected at config load).
75    /// - `store` (string, required iff `policy: redis`): name of a declared
76    ///   `stores:` entry to use as the counter backend.
77    /// - `group` (string, optional): prefixes the counter key so multiple
78    ///   nodes share one counter.
79    /// - `rejected_code` (integer 200-599, default `503`): status for
80    ///   over-limit requests.
81    /// - `rejected_msg` (string, optional): message placed in the rejection
82    ///   body (`{"error_msg": ...}`). Supports `{{namespace.path}}`
83    ///   references.
84    /// - `show_limit_quota_header` (bool, default `true`): emit the
85    ///   `X-RateLimit-*` headers onto the response.
86    /// - `allow_degradation` (bool, default `false`): on a counter-backend
87    ///   error, allow the request through instead of failing it.
88    ///
89    /// ```yaml
90    /// type: limit-count
91    /// config:
92    ///   count: 100
93    ///   time_window: 60
94    ///   key: "$remote_addr"
95    ///   policy: local
96    ///   rejected_code: 429
97    ///   show_limit_quota_header: true
98    /// ```
99    pub fn from_config(
100        config: &HashMap<String, serde_json::Value>,
101        resources: &Arc<PluginResources>,
102    ) -> Result<Self, String> {
103        let count = config
104            .get("count")
105            .and_then(|v| v.as_u64())
106            .filter(|n| *n > 0)
107            .ok_or("limit-count requires 'count' as an integer greater than 0")?;
108
109        let time_window = config
110            .get("time_window")
111            .and_then(|v| v.as_u64())
112            .filter(|n| *n > 0)
113            .ok_or("limit-count requires 'time_window' as an integer greater than 0 (seconds)")?;
114
115        let key_template = config
116            .get("key")
117            .and_then(|v| v.as_str())
118            .filter(|s| !s.is_empty())
119            .unwrap_or("$remote_addr");
120        // Discard warnings here — the compile-time walk (a later task)
121        // reports well-formed-but-unknown references; execution must not.
122        let key_template = Template::parse(key_template).0;
123
124        let policy = config
125            .get("policy")
126            .and_then(|v| v.as_str())
127            .unwrap_or("local");
128        // `redis` resolves a named `stores:` entry (cluster-shared counters);
129        // anything else goes through the local registry, which also produces
130        // the supported-policy list for unknown names.
131        let store = if policy == "redis" {
132            let name = config
133                .get("store")
134                .and_then(|v| v.as_str())
135                .filter(|s| !s.is_empty())
136                .ok_or_else(|| {
137                    "limit-count: policy 'redis' requires 'store' naming a declared stores: entry"
138                        .to_string()
139                })?;
140            resources.stores.load().counter_store(name)?
141        } else {
142            resources.counters.get(policy)?
143        };
144
145        let group = config
146            .get("group")
147            .and_then(|v| v.as_str())
148            .filter(|s| !s.is_empty())
149            .map(String::from);
150
151        let rejected_code = config
152            .get("rejected_code")
153            .and_then(|v| v.as_u64())
154            .filter(|n| (200..=599).contains(n))
155            .map(|n| n as u16)
156            .unwrap_or(503);
157
158        let rejected_msg = config
159            .get("rejected_msg")
160            .and_then(|v| v.as_str())
161            .filter(|s| !s.is_empty())
162            // Discard warnings here — the compile-time walk (a later task)
163            // reports well-formed-but-unknown references; execution must not.
164            .map(|s| Template::parse(s).0);
165
166        let show_limit_quota_header = config
167            .get("show_limit_quota_header")
168            .and_then(|v| v.as_bool())
169            .unwrap_or(true);
170
171        let allow_degradation = config
172            .get("allow_degradation")
173            .and_then(|v| v.as_bool())
174            .unwrap_or(false);
175
176        Ok(Self {
177            count,
178            window: Duration::from_secs(time_window),
179            key_template,
180            group,
181            rejected_code,
182            rejected_msg,
183            show_limit_quota_header,
184            allow_degradation,
185            store,
186        })
187    }
188
189    /// Resolves the per-request counter key: interpolates the `key` template
190    /// and, when it resolves to empty, falls back to the remote address
191    /// (matching APISIX). The `group` prefix, when set, is prepended so nodes
192    /// in the same group share one counter.
193    fn resolve_key(&self, ctx: &Context) -> String {
194        let mut key = self.key_template.render_with_legacy(ctx);
195        if key.is_empty() {
196            key = crate::vars::interpolate(ctx, "$remote_addr");
197        }
198        match &self.group {
199            Some(group) => format!("{}:{}", group, key),
200            None => key,
201        }
202    }
203
204    /// Writes the `X-RateLimit-*` quota headers onto the response.
205    fn set_quota_headers(&self, ctx: &mut Context, remaining: u64, reset: Duration) {
206        if !self.show_limit_quota_header {
207            return;
208        }
209        ctx.response.headers.insert(
210            "x-ratelimit-limit".to_string(),
211            vec![self.count.to_string()],
212        );
213        ctx.response.headers.insert(
214            "x-ratelimit-remaining".to_string(),
215            vec![remaining.to_string()],
216        );
217        // Whole seconds until the window resets (rounded up so a partial
218        // second is never reported as 0 while the window is still open).
219        let reset_secs = reset.as_secs_f64().ceil() as u64;
220        ctx.response.headers.insert(
221            "x-ratelimit-reset".to_string(),
222            vec![reset_secs.to_string()],
223        );
224    }
225}
226
227#[async_trait]
228impl Plugin for LimitCountPlugin {
229    fn plugin_type(&self) -> &str {
230        "limit-count"
231    }
232
233    async fn execute(&self, mut ctx: Context) -> PluginResult {
234        let key = self.resolve_key(&ctx);
235
236        let result = match self
237            .store
238            .incr_fixed_window(&key, self.count, self.window)
239            .await
240        {
241            Ok(r) => r,
242            Err(_) => {
243                // Counter backend failed. Fail open when configured to
244                // degrade, otherwise reject with a 500 through the error port.
245                if self.allow_degradation {
246                    return Ok(PluginOutput::success(ctx));
247                }
248                ctx.response.status_code = 500;
249                ctx.response.body = Bytes::from(r#"{"error_msg": "failed to limit count"}"#);
250                ctx.response.headers.insert(
251                    "content-type".to_string(),
252                    vec!["application/json".to_string()],
253                );
254                return Err(PluginExecutionError {
255                    context: ctx,
256                    error: GatewayError {
257                        node_id: String::new(),
258                        code: "RATE_LIMIT_UNAVAILABLE".to_string(),
259                        message: "failed to limit count".to_string(),
260                        metadata: HashMap::new(),
261                    },
262                });
263            }
264        };
265
266        if result.allowed {
267            self.set_quota_headers(&mut ctx, result.remaining, result.reset);
268            return Ok(PluginOutput::success(ctx));
269        }
270
271        // Rejected: quota headers show 0 remaining, then a JSON error body.
272        self.set_quota_headers(&mut ctx, 0, result.reset);
273        let msg = self
274            .rejected_msg
275            .as_ref()
276            .map(|t| t.render(&ctx).into_owned())
277            .unwrap_or_else(|| "Requests over the limit".to_string());
278        let body = serde_json::json!({ "error_msg": msg }).to_string();
279        ctx.response.status_code = self.rejected_code;
280        ctx.response.body = Bytes::from(body);
281        ctx.response.headers.insert(
282            "content-type".to_string(),
283            vec!["application/json".to_string()],
284        );
285
286        Ok(PluginOutput::on_port(ctx, "limited"))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
294
295    fn test_ctx() -> Context {
296        let mut headers = HashMap::new();
297        headers.insert("x-api-key".to_string(), vec!["abc123".to_string()]);
298        Context {
299            request: GatewayRequest {
300                method: "GET".to_string(),
301                path: "/api".to_string(),
302                host: "example.com".to_string(),
303                scheme: "http".to_string(),
304                headers,
305                query_params: HashMap::new(),
306                body: Bytes::new(),
307                remote_addr: "10.1.2.3:44321".to_string(),
308                protocol: Protocol::Http1,
309            },
310            response: GatewayResponse {
311                status_code: 0,
312                headers: HashMap::new(),
313                body: Bytes::new(),
314                stream: None,
315            },
316            message: HashMap::new(),
317            errors: Vec::new(),
318        }
319    }
320
321    fn plugin(config: serde_json::Value) -> Result<LimitCountPlugin, String> {
322        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
323        LimitCountPlugin::from_config(&map, &PluginResources::empty())
324    }
325
326    #[test]
327    fn test_config_requires_count_and_window() {
328        assert!(plugin(serde_json::json!({ "time_window": 60 })).is_err());
329        assert!(plugin(serde_json::json!({ "count": 10 })).is_err());
330        assert!(plugin(serde_json::json!({ "count": 0, "time_window": 60 })).is_err());
331        assert!(plugin(serde_json::json!({ "count": 10, "time_window": 0 })).is_err());
332        assert!(plugin(serde_json::json!({ "count": 10, "time_window": 60 })).is_ok());
333    }
334
335    /// `policy: redis` requires `store:`; a policy name that is neither
336    /// `local` nor `redis` is rejected with the supported list.
337    ///
338    /// (`LimitCountPlugin` isn't `Debug` — its `store: Arc<dyn CounterStore>`
339    /// field can't derive it — so `unwrap_err()` doesn't compile here; match
340    /// instead, as `src/stores/mod.rs`'s tests already do for the same reason.)
341    #[test]
342    fn test_redis_policy_requires_store() {
343        let err = match plugin(serde_json::json!({
344            "count": 1, "time_window": 60, "policy": "redis"
345        })) {
346            Ok(_) => panic!("policy 'redis' without 'store' should be rejected"),
347            Err(e) => e,
348        };
349        assert!(err.contains("requires 'store'"), "{err}");
350    }
351
352    #[test]
353    fn test_unknown_policy_rejected() {
354        let err = match plugin(serde_json::json!({
355            "count": 1, "time_window": 60, "policy": "memcached"
356        })) {
357            Ok(_) => panic!("policy 'memcached' should be rejected"),
358            Err(e) => e,
359        };
360        assert!(err.contains("unknown rate-limit policy"), "{err}");
361    }
362
363    /// `store:` must name a declared store; the error lists what exists.
364    #[cfg(feature = "redis-store")]
365    #[test]
366    fn test_redis_policy_unknown_store_rejected() {
367        let err = match plugin(serde_json::json!({
368            "count": 1, "time_window": 60, "policy": "redis", "store": "nope"
369        })) {
370            Ok(_) => panic!("policy 'redis' with an undeclared store should be rejected"),
371            Err(e) => e,
372        };
373        assert!(err.contains("unknown store 'nope'"), "{err}");
374    }
375
376    #[test]
377    fn test_key_interpolation() {
378        let ctx = test_ctx();
379
380        // default: remote_addr (port stripped by the var resolver)
381        let p = plugin(serde_json::json!({ "count": 10, "time_window": 60 })).unwrap();
382        assert_eq!(p.resolve_key(&ctx), "10.1.2.3");
383
384        // custom var template
385        let p = plugin(serde_json::json!({
386            "count": 10, "time_window": 60, "key": "$http_x_api_key"
387        }))
388        .unwrap();
389        assert_eq!(p.resolve_key(&ctx), "abc123");
390
391        // empty resolution falls back to remote_addr
392        let p = plugin(serde_json::json!({
393            "count": 10, "time_window": 60, "key": "$http_missing"
394        }))
395        .unwrap();
396        assert_eq!(p.resolve_key(&ctx), "10.1.2.3");
397
398        // group prefixes the key
399        let p = plugin(serde_json::json!({
400            "count": 10, "time_window": 60, "key": "$remote_addr", "group": "svc"
401        }))
402        .unwrap();
403        assert_eq!(p.resolve_key(&ctx), "svc:10.1.2.3");
404    }
405
406    #[test]
407    fn test_key_superset_template_and_legacy_dollar() {
408        // `key` must render both the new `{{...}}` template syntax and the
409        // legacy `$var` syntax in the same value (superset behavior).
410        let ctx = test_ctx();
411        let p = plugin(serde_json::json!({
412            "count": 10, "time_window": 60, "key": "{{client.ip}}:$http_x_api_key"
413        }))
414        .unwrap();
415        assert_eq!(p.resolve_key(&ctx), "10.1.2.3:abc123");
416    }
417
418    #[tokio::test]
419    async fn test_rejects_after_count_requests() {
420        let count = 3u64;
421        let p = plugin(serde_json::json!({
422            "count": count, "time_window": 60, "rejected_code": 429
423        }))
424        .unwrap();
425
426        // First `count` requests pass.
427        for i in 0..count {
428            let out = p.execute(test_ctx()).await.unwrap();
429            assert!(out.port.is_none(), "request {i} should pass");
430            assert_eq!(
431                out.context.response.headers.get("x-ratelimit-limit"),
432                Some(&vec!["3".to_string()])
433            );
434            assert_eq!(
435                out.context.response.headers.get("x-ratelimit-remaining"),
436                Some(&vec![(count - 1 - i).to_string()])
437            );
438        }
439
440        // The next one is rejected on `limited`.
441        let out = p.execute(test_ctx()).await.unwrap();
442        assert_eq!(out.port, Some("limited"));
443        assert_eq!(out.context.response.status_code, 429);
444        assert_eq!(
445            out.context.response.headers.get("x-ratelimit-remaining"),
446            Some(&vec!["0".to_string()])
447        );
448        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
449        assert!(body.contains("error_msg"), "{body}");
450    }
451
452    #[tokio::test]
453    async fn test_rejected_msg_used() {
454        let p = plugin(serde_json::json!({
455            "count": 1, "time_window": 60, "rejected_msg": "slow down"
456        }))
457        .unwrap();
458        assert!(p.execute(test_ctx()).await.unwrap().port.is_none());
459        let out = p.execute(test_ctx()).await.unwrap();
460        assert_eq!(out.port, Some("limited"));
461        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
462        assert!(body.contains("slow down"), "{body}");
463    }
464
465    #[tokio::test]
466    async fn test_rejected_msg_renders_template() {
467        // `rejected_msg` must render `{{...}}` references per request (Sweep B).
468        let p = plugin(serde_json::json!({
469            "count": 1, "time_window": 60, "rejected_msg": "blocked method {{request.method}}"
470        }))
471        .unwrap();
472        assert!(p.execute(test_ctx()).await.unwrap().port.is_none());
473        let out = p.execute(test_ctx()).await.unwrap();
474        assert_eq!(out.port, Some("limited"));
475        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
476        assert!(body.contains("blocked method GET"), "{body}");
477    }
478
479    #[tokio::test]
480    async fn test_show_limit_quota_header_false_omits_headers() {
481        let p = plugin(serde_json::json!({
482            "count": 5, "time_window": 60, "show_limit_quota_header": false
483        }))
484        .unwrap();
485        let ctx = p.execute(test_ctx()).await.unwrap().context;
486        assert!(!ctx.response.headers.contains_key("x-ratelimit-limit"));
487    }
488}