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