featherbit/plugins/native/limit_conn.rs
1//! Concurrent-request limiting (`limit-conn`), ported from APISIX's
2//! `apisix/plugins/limit-conn`.
3//!
4//! Concurrency is a property of the *span* between entering and leaving the
5//! upstream call, which a single featherbit node cannot observe — a node runs
6//! once, at one point in the graph. The behavior is therefore expressed as a
7//! **pair of nodes** sharing one in-flight counter:
8//!
9//! - an **acquire** node placed *before* `upstream`, which increments the
10//! counter and rejects when the ceiling is reached, and
11//! - a **release** node placed *after* `upstream` (on **both** the success and
12//! error paths), which decrements the counter.
13//!
14//! Both nodes are configured with the same `key` template (and identical
15//! `conn`/`burst`), so [`crate::traffic::ConnRegistry`] hands them the same
16//! [`std::sync::atomic::AtomicI64`]. This is the same "two phases, one shared
17//! key" shape `proxy-rewrite` uses for request/response.
18//!
19//! # Wiring
20//!
21//! ```text
22//! ┌──────────────────┐ ┌──────────┐ ┌──────────────────┐
23//! listener →│ limit-conn │success →│ upstream │success →│ limit-conn │→ client
24//! │ (phase=acquire) │ │ │ │ (phase=release) │
25//! └──────────────────┘ └──────────┘ └──────────────────┘
26//! │ limited │ error ▲
27//! ▼ └─────────────────────┘
28//! client.in (release also runs on the error path
29//! (503 rejection) so the counter always decrements)
30//! ```
31//!
32//! The acquire node's `limited` port goes to `client.in`: an over-limit
33//! request short-circuits straight to the client with the rejection
34//! response. The release node must sit on *every* path out of `upstream`
35//! (success and error) so a failed upstream call still frees the slot.
36
37use async_trait::async_trait;
38use bytes::Bytes;
39use std::collections::HashMap;
40use std::sync::atomic::Ordering;
41use std::sync::Arc;
42
43use crate::context::Context;
44use crate::plugins::resources::PluginResources;
45use crate::plugins::{Plugin, PluginOutput, PluginResult};
46use crate::vars::template::Template;
47
48/// Which half of the pair this node is.
49#[derive(Debug, Clone, Copy, PartialEq)]
50enum Role {
51 /// Runs before `upstream`: increments the counter, rejects at the ceiling.
52 Acquire,
53 /// Runs after `upstream`: decrements the counter (floored at zero).
54 Release,
55}
56
57/// One node of a `limit-conn` acquire/release pair.
58///
59/// Holds a handle to the process-wide [`crate::traffic::ConnRegistry`]; the
60/// per-key counter is resolved at request time from the interpolated `key` so
61/// concurrency is measured per client (or per whatever the key selects), shared
62/// across both nodes of the pair.
63pub struct LimitConnPlugin {
64 role: Role,
65 /// Maximum sustained concurrent requests.
66 conn: i64,
67 /// Extra concurrent requests tolerated above `conn`.
68 burst: i64,
69 /// Key template (interpolated per request unless `key_type` is `constant`).
70 key: String,
71 /// Pre-parsed `key`: supports `{{namespace.path}}` references and legacy
72 /// `$var` interpolation (see [`Template::render_with_legacy`]). Only used
73 /// when `key_constant` is false.
74 key_tpl: Template,
75 /// When `true`, `key` is used verbatim rather than interpolated.
76 key_constant: bool,
77 /// Status returned when the limit is exceeded.
78 rejected_code: u16,
79 /// Optional human-readable rejection message (JSON `{"error_msg": ...}`).
80 /// Supports `{{namespace.path}}` references (no legacy `$var`
81 /// interpolation — this field never supported it, so this sweep must not
82 /// start).
83 rejected_msg: Option<Template>,
84 resources: Arc<PluginResources>,
85}
86
87impl LimitConnPlugin {
88 /// Builds one node of the pair from node config.
89 ///
90 /// Accepted keys:
91 /// - `phase` / `role` (string, **required**): `acquire` (before upstream)
92 /// or `release` (after upstream). Any other value is a config error.
93 /// - `conn` (integer, default `20`): maximum sustained concurrent requests.
94 /// Must be `> 0`.
95 /// - `burst` (integer, default `0`): extra concurrent requests tolerated
96 /// above `conn`. Must be `>= 0`. The hard ceiling is `conn + burst`.
97 /// - `key` (string template, default `"$remote_addr"`): the concurrency
98 /// key, rendered per request — supports `{{namespace.path}}` references
99 /// plus legacy `$var` interpolation (see
100 /// [`crate::vars::template::Template::render_with_legacy`]) unless
101 /// `key_type: constant`. Both nodes of a pair **must** use the same
102 /// `key` and `conn`/`burst` so they share one counter.
103 /// - `key_type` (string, default `var`): `constant` uses `key` verbatim;
104 /// any other value (`var`, `var_combination`) interpolates it.
105 /// - `rejected_code` (integer, default `503`): status for over-limit
106 /// requests.
107 /// - `rejected_msg` (string, optional): message returned as a JSON body
108 /// `{"error_msg": "..."}` on rejection. Supports `{{namespace.path}}`
109 /// references.
110 /// - `default_conn_delay` (number, optional): accepted for APISIX config
111 /// compatibility but ignored — featherbit rejects rather than delays.
112 ///
113 /// ```yaml
114 /// # before upstream
115 /// type: limit-conn
116 /// config:
117 /// phase: acquire
118 /// conn: 100
119 /// burst: 50
120 /// key: $remote_addr
121 /// rejected_code: 503
122 /// ---
123 /// # after upstream (on both success and error paths)
124 /// type: limit-conn
125 /// config:
126 /// phase: release
127 /// conn: 100
128 /// burst: 50
129 /// key: $remote_addr
130 /// ```
131 pub fn from_config(
132 config: &HashMap<String, serde_json::Value>,
133 resources: &Arc<PluginResources>,
134 ) -> Result<Self, String> {
135 let role = match config
136 .get("phase")
137 .or_else(|| config.get("role"))
138 .and_then(|v| v.as_str())
139 {
140 Some("acquire") => Role::Acquire,
141 Some("release") => Role::Release,
142 Some(other) => {
143 return Err(format!(
144 "limit-conn: unknown phase/role '{}' (expected 'acquire' or 'release')",
145 other
146 ))
147 }
148 None => {
149 return Err(
150 "limit-conn: 'phase' (or 'role') is required: 'acquire' or 'release'"
151 .to_string(),
152 )
153 }
154 };
155
156 let conn = config.get("conn").and_then(|v| v.as_i64()).unwrap_or(20);
157 if conn <= 0 {
158 return Err(format!(
159 "limit-conn: 'conn' must be a positive integer, got {}",
160 conn
161 ));
162 }
163
164 let burst = config.get("burst").and_then(|v| v.as_i64()).unwrap_or(0);
165 if burst < 0 {
166 return Err(format!(
167 "limit-conn: 'burst' must be non-negative, got {}",
168 burst
169 ));
170 }
171
172 let key = config
173 .get("key")
174 .and_then(|v| v.as_str())
175 .unwrap_or("$remote_addr")
176 .to_string();
177 // Discard warnings here — the compile-time walk (a later task)
178 // reports well-formed-but-unknown references; execution must not.
179 let key_tpl = Template::parse(&key).0;
180
181 let key_constant = matches!(
182 config.get("key_type").and_then(|v| v.as_str()),
183 Some("constant")
184 );
185
186 let rejected_code = config
187 .get("rejected_code")
188 .and_then(|v| v.as_u64())
189 .map(|c| c as u16)
190 .unwrap_or(503);
191
192 let rejected_msg = config
193 .get("rejected_msg")
194 .and_then(|v| v.as_str())
195 // Discard warnings here — the compile-time walk (a later task)
196 // reports well-formed-but-unknown references; execution must not.
197 .map(|s| Template::parse(s).0);
198
199 Ok(Self {
200 role,
201 conn,
202 burst,
203 key,
204 key_tpl,
205 key_constant,
206 rejected_code,
207 rejected_msg,
208 resources: resources.clone(),
209 })
210 }
211
212 /// Resolves the concurrency key for this request.
213 fn resolve_key(&self, ctx: &Context) -> String {
214 if self.key_constant {
215 self.key.clone()
216 } else {
217 self.key_tpl.render_with_legacy(ctx)
218 }
219 }
220}
221
222#[async_trait]
223impl Plugin for LimitConnPlugin {
224 fn plugin_type(&self) -> &str {
225 "limit-conn"
226 }
227
228 async fn execute(&self, mut ctx: Context) -> PluginResult {
229 let key = self.resolve_key(&ctx);
230 let counter = self.resources.traffic.conn.counter(&key);
231
232 match self.role {
233 Role::Acquire => {
234 // fetch_add returns the value *before* the increment, i.e. the
235 // number of requests already in flight.
236 let in_flight = counter.fetch_add(1, Ordering::SeqCst);
237 if in_flight >= self.conn + self.burst {
238 // Over the ceiling — undo our increment and reject.
239 counter.fetch_sub(1, Ordering::SeqCst);
240
241 ctx.response.status_code = self.rejected_code;
242 let body = match &self.rejected_msg {
243 Some(msg) => {
244 let rendered = msg.render(&ctx).into_owned();
245 format!(r#"{{"error_msg":{}}}"#, json_string(&rendered))
246 }
247 None => r#"{"error":"limit_conn_exceeded"}"#.to_string(),
248 };
249 ctx.response.body = Bytes::from(body);
250 ctx.response.headers.insert(
251 "content-type".to_string(),
252 vec!["application/json".to_string()],
253 );
254
255 return Ok(PluginOutput::on_port(ctx, "limited"));
256 }
257 Ok(PluginOutput::success(ctx))
258 }
259 Role::Release => {
260 // Decrement, floored at zero so a stray release (e.g. an
261 // acquire that rejected but was still wired to a release) can
262 // never drive the counter negative.
263 let mut cur = counter.load(Ordering::SeqCst);
264 while cur > 0 {
265 match counter.compare_exchange_weak(
266 cur,
267 cur - 1,
268 Ordering::SeqCst,
269 Ordering::SeqCst,
270 ) {
271 Ok(_) => break,
272 Err(actual) => cur = actual,
273 }
274 }
275 Ok(PluginOutput::success(ctx))
276 }
277 }
278 }
279}
280
281/// Minimal JSON string escaping for the rejection message.
282fn json_string(s: &str) -> String {
283 let mut out = String::with_capacity(s.len() + 2);
284 out.push('"');
285 for c in s.chars() {
286 match c {
287 '"' => out.push_str("\\\""),
288 '\\' => out.push_str("\\\\"),
289 '\n' => out.push_str("\\n"),
290 '\r' => out.push_str("\\r"),
291 '\t' => out.push_str("\\t"),
292 c => out.push(c),
293 }
294 }
295 out.push('"');
296 out
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
303
304 fn ctx() -> Context {
305 Context {
306 request: GatewayRequest {
307 method: "GET".to_string(),
308 path: "/".to_string(),
309 host: "localhost".to_string(),
310 scheme: "http".to_string(),
311 headers: HashMap::new(),
312 query_params: HashMap::new(),
313 body: Bytes::new(),
314 remote_addr: "10.0.0.1:5000".to_string(),
315 protocol: Protocol::Http1,
316 },
317 response: GatewayResponse {
318 status_code: 0,
319 headers: HashMap::new(),
320 body: Bytes::new(),
321 stream: None,
322 },
323 message: HashMap::new(),
324 errors: Vec::new(),
325 }
326 }
327
328 fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
329 pairs
330 .iter()
331 .map(|(k, v)| (k.to_string(), v.clone()))
332 .collect()
333 }
334
335 #[test]
336 fn test_unknown_role_and_bad_thresholds_fail() {
337 let r = PluginResources::empty();
338 assert!(
339 LimitConnPlugin::from_config(&cfg(&[("phase", serde_json::json!("nope"))]), &r)
340 .is_err()
341 );
342 assert!(
343 LimitConnPlugin::from_config(&cfg(&[]), &r).is_err(),
344 "missing phase must fail"
345 );
346 assert!(LimitConnPlugin::from_config(
347 &cfg(&[
348 ("phase", serde_json::json!("acquire")),
349 ("conn", serde_json::json!(0))
350 ]),
351 &r
352 )
353 .is_err());
354 assert!(LimitConnPlugin::from_config(
355 &cfg(&[
356 ("phase", serde_json::json!("acquire")),
357 ("burst", serde_json::json!(-1))
358 ]),
359 &r
360 )
361 .is_err());
362 }
363
364 #[tokio::test]
365 async fn test_rejected_msg_renders_template() {
366 let r = PluginResources::empty();
367 let acquire = LimitConnPlugin::from_config(
368 &cfg(&[
369 ("phase", serde_json::json!("acquire")),
370 ("conn", serde_json::json!(1)),
371 ("key", serde_json::json!("const-conn")),
372 ("key_type", serde_json::json!("constant")),
373 (
374 "rejected_msg",
375 serde_json::json!("too many from {{client.ip}}"),
376 ),
377 ]),
378 &r,
379 )
380 .unwrap();
381 acquire.execute(ctx()).await.unwrap();
382 let out = acquire.execute(ctx()).await.unwrap();
383 assert_eq!(out.port, Some("limited"));
384 let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
385 assert!(body.contains("too many from 10.0.0.1"), "{body}");
386 }
387
388 #[tokio::test]
389 async fn test_acquire_increments_rejects_at_limit_release_decrements() {
390 // Shared resources → shared counter registry across the pair.
391 let r = PluginResources::empty();
392 let acquire = LimitConnPlugin::from_config(
393 &cfg(&[
394 ("phase", serde_json::json!("acquire")),
395 ("conn", serde_json::json!(3)),
396 ("key", serde_json::json!("$remote_addr")),
397 ("rejected_code", serde_json::json!(503)),
398 ]),
399 &r,
400 )
401 .unwrap();
402 let release = LimitConnPlugin::from_config(
403 &cfg(&[
404 ("phase", serde_json::json!("release")),
405 ("conn", serde_json::json!(3)),
406 ("key", serde_json::json!("$remote_addr")),
407 ]),
408 &r,
409 )
410 .unwrap();
411
412 // conn=3, burst=0 → 3 concurrent allowed, the 4th rejected.
413 for i in 0..3 {
414 let out = acquire.execute(ctx()).await.unwrap();
415 assert!(out.port.is_none(), "acquire #{} should be allowed", i + 1);
416 }
417 let rejected = acquire
418 .execute(ctx())
419 .await
420 .expect("4th concurrent acquire completes with a `limited` outcome");
421 assert_eq!(rejected.port, Some("limited"));
422 assert_eq!(rejected.context.response.status_code, 503);
423
424 // Release one slot → the next acquire succeeds again.
425 release.execute(ctx()).await.unwrap();
426 let out = acquire.execute(ctx()).await.unwrap();
427 assert!(
428 out.port.is_none(),
429 "acquire should succeed after a release freed a slot"
430 );
431 }
432
433 #[tokio::test]
434 async fn test_release_floors_at_zero() {
435 let r = PluginResources::empty();
436 let release = LimitConnPlugin::from_config(
437 &cfg(&[
438 ("phase", serde_json::json!("release")),
439 ("conn", serde_json::json!(5)),
440 ("key", serde_json::json!("const")),
441 ("key_type", serde_json::json!("constant")),
442 ]),
443 &r,
444 )
445 .unwrap();
446 // Extra releases must not drive the counter negative.
447 release.execute(ctx()).await.unwrap();
448 release.execute(ctx()).await.unwrap();
449 assert_eq!(r.traffic.conn.counter("const").load(Ordering::SeqCst), 0);
450 }
451}