featherbit/plugins/native/
limit_count.rs1use 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
24pub struct LimitCountPlugin {
35 count: u64,
37 window: Duration,
39 key_template: Template,
43 group: Option<String>,
45 rejected_code: u16,
47 rejected_msg: Option<Template>,
51 show_limit_quota_header: bool,
53 allow_degradation: bool,
56 store: Arc<dyn CounterStore>,
58}
59
60impl LimitCountPlugin {
61 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 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 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 .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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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}