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}