Skip to main content

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}