Skip to main content

featherbit/plugins/native/
fault_injection.rs

1//! Fault injection plugin (`fault-injection`) — a faithful subset of Apache
2//! APISIX's `fault-injection` plugin for chaos/resilience testing.
3//!
4//! Injects an artificial `delay` and/or an `abort` response into matching
5//! requests. Both faults are gated independently by a `percentage` sample and
6//! a `vars` condition (APISIX triple-array expressions, see [`crate::vars`]).
7//!
8//! **Early-exit wiring**: an aborted request has the configured response
9//! already written onto `Context.response` and exits through the node's
10//! dedicated **`abort`** output port; non-aborted requests (nothing
11//! triggered, or delay-only) continue through the **success** port. Wire
12//! `abort` to a pass-through path (e.g. straight to `client.in`, or an
13//! `error-handler` that preserves the prepared response) so the injected
14//! status/body reach the client, and wire `success` to the rest of the
15//! pipeline.
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20use std::time::Duration;
21
22use crate::context::Context;
23use crate::plugins::{Plugin, PluginOutput, PluginResult};
24use crate::vars::template::Template;
25use crate::vars::Expr;
26
27/// Injects delays and/or abort responses into matching requests.
28///
29/// Order matches APISIX: the delay (if it triggers) is applied first, then
30/// the abort check runs. `body`, and string header values, support
31/// `{{namespace.path}}` references plus legacy `$var` interpolation against
32/// the request.
33pub struct FaultInjectionPlugin {
34    abort: Option<AbortRule>,
35    delay: Option<DelayRule>,
36}
37
38struct AbortRule {
39    http_status: u16,
40    /// Body template: supports `{{namespace.path}}` references and legacy
41    /// `$var` interpolation (see [`Template::render_with_legacy`]); empty
42    /// body when unset.
43    body: Option<Template>,
44    /// Lowercased header name → value template.
45    headers: Vec<(String, Template)>,
46    percentage: Option<u8>,
47    /// OR-ed list of expressions (APISIX shape); `None` means always match.
48    vars: Option<Vec<Expr>>,
49}
50
51struct DelayRule {
52    duration: Duration,
53    percentage: Option<u8>,
54    vars: Option<Vec<Expr>>,
55}
56
57/// Draws a pseudo-random number in `0..100`.
58///
59/// Uses a freshly seeded `RandomState` hasher instead of a rand crate: each
60/// `RandomState::new()` carries new per-call keys, so `finish()` yields a
61/// cheap, statistically casual value. Not cryptographic and not a
62/// deterministic sequence — good enough for percentage sampling.
63fn roll_percent() -> u8 {
64    use std::collections::hash_map::RandomState;
65    use std::hash::{BuildHasher, Hasher};
66    (RandomState::new().build_hasher().finish() % 100) as u8
67}
68
69/// APISIX `sample_hit`: no percentage means always hit; `0` never hits and
70/// `100` always hits.
71fn sample_hit(percentage: Option<u8>) -> bool {
72    match percentage {
73        None => true,
74        Some(p) => roll_percent() < p,
75    }
76}
77
78/// True when `vars` is unset, or when any expression in the list matches
79/// (APISIX ORs across the `vars` items and ANDs within each).
80fn vars_match(vars: &Option<Vec<Expr>>, ctx: &Context) -> bool {
81    match vars {
82        None => true,
83        Some(exprs) => exprs.iter().any(|e| e.eval(ctx)),
84    }
85}
86
87/// Parses the APISIX `vars` shape for this plugin: an array whose items are
88/// each a full expression (OR-ed together). Two leniencies over the strict
89/// shape, both resolved at config load:
90/// - an item that is a `["AND"|"OR", rule...]` group is wrapped as a
91///   single-rule expression;
92/// - a "flat" list of bare rules (`[["arg_x", "==", "1"], ...]`) is treated
93///   as one AND-ed expression.
94fn parse_or_vars(v: &serde_json::Value, field: &str) -> Result<Vec<Expr>, String> {
95    let items = v
96        .as_array()
97        .ok_or_else(|| format!("{field}.vars must be an array"))?;
98
99    // Flat shape: first item's first element is a plain string that is not a
100    // logical operator — treat the whole array as a single expression.
101    let first_str = items
102        .first()
103        .and_then(|i| i.as_array())
104        .and_then(|a| a.first())
105        .and_then(|f| f.as_str());
106    if let Some(s) = first_str {
107        if !s.eq_ignore_ascii_case("and") && !s.eq_ignore_ascii_case("or") {
108            let expr = Expr::parse(v).map_err(|e| format!("{field}.vars: {e}"))?;
109            return Ok(vec![expr]);
110        }
111    }
112
113    items
114        .iter()
115        .map(|item| {
116            let starts_with_op = item
117                .as_array()
118                .and_then(|a| a.first())
119                .and_then(|f| f.as_str())
120                .is_some();
121            let parsed = if starts_with_op {
122                // ["AND"/"OR", ...] is one nested rule: wrap it into a rule list.
123                Expr::parse(&serde_json::Value::Array(vec![item.clone()]))
124            } else {
125                Expr::parse(item)
126            };
127            parsed.map_err(|e| format!("{field}.vars: {e}"))
128        })
129        .collect()
130}
131
132/// Parses `percentage` as an integer in `0..=100`.
133fn parse_percentage(
134    obj: &serde_json::Map<String, serde_json::Value>,
135    field: &str,
136) -> Result<Option<u8>, String> {
137    match obj.get("percentage") {
138        None => Ok(None),
139        Some(v) => {
140            let n = v
141                .as_u64()
142                .filter(|n| *n <= 100)
143                .ok_or_else(|| format!("{field}.percentage must be an integer 0-100"))?;
144            Ok(Some(n as u8))
145        }
146    }
147}
148
149/// Accepts `headers` as a map (`{name: value}`) or as the UI editor's array
150/// form (`[{name, value}]`); scalar values are stringified, other shapes are
151/// rejected. Header names are lowercased.
152fn parse_headers(v: &serde_json::Value, field: &str) -> Result<Vec<(String, String)>, String> {
153    let scalar = |v: &serde_json::Value| -> Option<String> {
154        match v {
155            serde_json::Value::String(s) => Some(s.clone()),
156            serde_json::Value::Number(n) => Some(n.to_string()),
157            serde_json::Value::Bool(b) => Some(b.to_string()),
158            _ => None,
159        }
160    };
161    match v {
162        serde_json::Value::Object(m) => m
163            .iter()
164            .map(|(k, v)| {
165                scalar(v)
166                    .map(|s| (k.to_lowercase(), s))
167                    .ok_or_else(|| format!("{field}['{k}'] must be a scalar value"))
168            })
169            .collect(),
170        serde_json::Value::Array(items) => {
171            let mut out = Vec::new();
172            for item in items {
173                let obj = item.as_object().ok_or_else(|| {
174                    format!("{field} entries must be objects with 'name' and 'value'")
175                })?;
176                let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
177                if name.trim().is_empty() {
178                    continue; // blank row left in the UI editor
179                }
180                let value = match obj.get("value") {
181                    None => String::new(),
182                    Some(v) => scalar(v)
183                        .ok_or_else(|| format!("{field}['{name}'] must be a scalar value"))?,
184                };
185                out.push((name.to_lowercase(), value));
186            }
187            Ok(out)
188        }
189        _ => Err(format!(
190            "{field} must be a map of name: value or a list of {{name, value}} objects"
191        )),
192    }
193}
194
195impl FaultInjectionPlugin {
196    /// Builds the plugin from node config. At least one of `abort` / `delay`
197    /// is required; expressions and shapes are validated here at config load.
198    ///
199    /// Accepted keys:
200    /// - `abort` (object): injected response.
201    ///   - `http_status` (integer >= 200, **required**): response status.
202    ///   - `body` (string): response body; supports `{{namespace.path}}`
203    ///     references plus legacy `$var` interpolation. Empty body when
204    ///     unset.
205    ///   - `headers` (map `{name: value}` or array `[{name, value}]`):
206    ///     response headers; string values support `{{namespace.path}}`
207    ///     references plus legacy `$var` interpolation.
208    ///   - `percentage` (integer 0-100): chance the abort triggers; unset
209    ///     means always.
210    ///   - `vars` (array): APISIX condition expressions, OR-ed across items.
211    /// - `delay` (object): injected latency.
212    ///   - `duration` (number, seconds, may be fractional, **required**).
213    ///   - `percentage`, `vars`: same gating as for `abort`.
214    ///
215    /// ```yaml
216    /// type: fault-injection
217    /// config:
218    ///   delay:
219    ///     duration: 0.5
220    ///     percentage: 30
221    ///   abort:
222    ///     http_status: 503
223    ///     body: '{"error": "injected for $uri"}'
224    ///     percentage: 10
225    ///     vars:
226    ///       - [["arg_debug", "==", "1"]]
227    /// ```
228    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
229        let abort = match config.get("abort") {
230            None => None,
231            Some(v) => {
232                let obj = v.as_object().ok_or("abort must be an object")?;
233                let http_status = obj
234                    .get("http_status")
235                    .and_then(|v| v.as_u64())
236                    .filter(|n| (200..=599).contains(n))
237                    .ok_or("abort.http_status is required and must be an integer >= 200")?
238                    as u16;
239                // Discard warnings here — the compile-time walk (a later
240                // task) reports well-formed-but-unknown references;
241                // execution must not.
242                let body = match obj.get("body") {
243                    None => None,
244                    Some(v) => {
245                        let s = v.as_str().ok_or("abort.body must be a string")?;
246                        Some(Template::parse(s).0)
247                    }
248                };
249                let headers = match obj.get("headers") {
250                    None => Vec::new(),
251                    Some(v) => parse_headers(v, "abort.headers")?
252                        .into_iter()
253                        .map(|(name, value)| (name, Template::parse(&value).0))
254                        .collect(),
255                };
256                let percentage = parse_percentage(obj, "abort")?;
257                let vars = match obj.get("vars") {
258                    None => None,
259                    Some(v) => Some(parse_or_vars(v, "abort")?),
260                };
261                Some(AbortRule {
262                    http_status,
263                    body,
264                    headers,
265                    percentage,
266                    vars,
267                })
268            }
269        };
270
271        let delay = match config.get("delay") {
272            None => None,
273            Some(v) => {
274                let obj = v.as_object().ok_or("delay must be an object")?;
275                let duration = obj
276                    .get("duration")
277                    .and_then(|v| v.as_f64())
278                    .filter(|d| *d >= 0.0 && d.is_finite())
279                    .ok_or(
280                        "delay.duration is required and must be a non-negative number of seconds",
281                    )?;
282                let percentage = parse_percentage(obj, "delay")?;
283                let vars = match obj.get("vars") {
284                    None => None,
285                    Some(v) => Some(parse_or_vars(v, "delay")?),
286                };
287                Some(DelayRule {
288                    duration: Duration::from_secs_f64(duration),
289                    percentage,
290                    vars,
291                })
292            }
293        };
294
295        if abort.is_none() && delay.is_none() {
296            return Err("fault-injection requires at least one of 'abort' or 'delay'".to_string());
297        }
298
299        Ok(Self { abort, delay })
300    }
301}
302
303#[async_trait]
304impl Plugin for FaultInjectionPlugin {
305    fn plugin_type(&self) -> &str {
306        "fault-injection"
307    }
308
309    async fn execute(&self, mut ctx: Context) -> PluginResult {
310        if let Some(delay) = &self.delay {
311            if sample_hit(delay.percentage) && vars_match(&delay.vars, &ctx) {
312                tokio::time::sleep(delay.duration).await;
313            }
314        }
315
316        if let Some(abort) = &self.abort {
317            if sample_hit(abort.percentage) && vars_match(&abort.vars, &ctx) {
318                // Interpolate against the request state before mutating the response.
319                let body = abort
320                    .body
321                    .as_ref()
322                    .map(|b| b.render_with_legacy(&ctx))
323                    .unwrap_or_default();
324                let headers: Vec<(String, String)> = abort
325                    .headers
326                    .iter()
327                    .map(|(name, tmpl)| (name.clone(), tmpl.render_with_legacy(&ctx)))
328                    .collect();
329
330                ctx.response.status_code = abort.http_status;
331                ctx.response.body = Bytes::from(body);
332                for (name, value) in headers {
333                    ctx.response.headers.insert(name, vec![value]);
334                }
335
336                // The abort response is fully prepared: exit on the
337                // dedicated `abort` port rather than `error`, and rather
338                // than `success` (which would continue into `upstream`).
339                return Ok(PluginOutput::on_port(ctx, "abort"));
340            }
341        }
342
343        Ok(PluginOutput::success(ctx))
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
351    use std::time::Instant;
352
353    fn test_ctx() -> Context {
354        let mut query = HashMap::new();
355        query.insert("name".to_string(), vec!["jack".to_string()]);
356        Context {
357            request: GatewayRequest {
358                method: "GET".to_string(),
359                path: "/api/users".to_string(),
360                host: "example.com".to_string(),
361                scheme: "http".to_string(),
362                headers: HashMap::new(),
363                query_params: query,
364                body: Bytes::new(),
365                remote_addr: "10.1.2.3:44321".to_string(),
366                protocol: Protocol::Http1,
367            },
368            response: GatewayResponse {
369                status_code: 0,
370                headers: HashMap::new(),
371                body: Bytes::new(),
372                stream: None,
373            },
374            message: HashMap::new(),
375            errors: Vec::new(),
376        }
377    }
378
379    fn plugin(config: serde_json::Value) -> Result<FaultInjectionPlugin, String> {
380        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
381        FaultInjectionPlugin::from_config(&map)
382    }
383
384    #[tokio::test]
385    async fn test_abort_exits_on_abort_port() {
386        let p = plugin(serde_json::json!({
387            "abort": {
388                "http_status": 503,
389                "body": "injected for $uri",
390                "headers": { "X-Fault": "yes" }
391            }
392        }))
393        .unwrap();
394
395        let out = p.execute(test_ctx()).await.unwrap();
396        assert_eq!(out.port, Some("abort"));
397        let ctx = out.context;
398        assert_eq!(ctx.response.status_code, 503);
399        assert_eq!(ctx.response.body, Bytes::from("injected for /api/users"));
400        assert_eq!(
401            ctx.response.headers.get("x-fault"),
402            Some(&vec!["yes".to_string()])
403        );
404    }
405
406    #[tokio::test]
407    async fn test_abort_vars_gate() {
408        // OR-of-expressions shape: second expression matches.
409        let p = plugin(serde_json::json!({
410            "abort": {
411                "http_status": 500,
412                "vars": [
413                    [["arg_name", "==", "nope"]],
414                    [["arg_name", "==", "jack"]]
415                ]
416            }
417        }))
418        .unwrap();
419        let out = p.execute(test_ctx()).await.unwrap();
420        assert_eq!(out.port, Some("abort"));
421
422        // No expression matches -> passthrough on success.
423        let p = plugin(serde_json::json!({
424            "abort": {
425                "http_status": 500,
426                "vars": [[["arg_name", "==", "jill"]]]
427            }
428        }))
429        .unwrap();
430        let out = p.execute(test_ctx()).await.unwrap();
431        assert_eq!(out.port, None);
432        assert_eq!(out.context.response.status_code, 0);
433    }
434
435    #[tokio::test]
436    async fn test_flat_vars_shape_accepted() {
437        let p = plugin(serde_json::json!({
438            "abort": {
439                "http_status": 500,
440                "vars": [["arg_name", "==", "jack"]]
441            }
442        }))
443        .unwrap();
444        let out = p.execute(test_ctx()).await.unwrap();
445        assert_eq!(out.port, Some("abort"));
446    }
447
448    #[tokio::test]
449    async fn test_percentage_edges() {
450        // 0% never aborts, 100% always aborts.
451        let p0 = plugin(serde_json::json!({
452            "abort": { "http_status": 500, "percentage": 0 }
453        }))
454        .unwrap();
455        let p100 = plugin(serde_json::json!({
456            "abort": { "http_status": 500, "percentage": 100 }
457        }))
458        .unwrap();
459        for _ in 0..20 {
460            let out0 = p0.execute(test_ctx()).await.unwrap();
461            assert_eq!(out0.port, None);
462            let out100 = p100.execute(test_ctx()).await.unwrap();
463            assert_eq!(out100.port, Some("abort"));
464        }
465    }
466
467    #[tokio::test]
468    async fn test_delay_sleeps() {
469        let p = plugin(serde_json::json!({
470            "delay": { "duration": 0.05 }
471        }))
472        .unwrap();
473        let start = Instant::now();
474        let out = p.execute(test_ctx()).await.unwrap();
475        assert!(start.elapsed() >= Duration::from_millis(45));
476        assert_eq!(out.context.response.status_code, 0);
477    }
478
479    #[tokio::test]
480    async fn test_delay_vars_gate_skips_sleep() {
481        let p = plugin(serde_json::json!({
482            "delay": {
483                "duration": 5.0,
484                "vars": [[["arg_name", "==", "nobody"]]]
485            }
486        }))
487        .unwrap();
488        let start = Instant::now();
489        p.execute(test_ctx()).await.unwrap();
490        assert!(start.elapsed() < Duration::from_secs(1));
491    }
492
493    #[test]
494    fn test_config_errors() {
495        // Neither abort nor delay.
496        assert!(plugin(serde_json::json!({})).is_err());
497        // abort without http_status.
498        assert!(plugin(serde_json::json!({ "abort": { "body": "x" } })).is_err());
499        // http_status below 200.
500        assert!(plugin(serde_json::json!({ "abort": { "http_status": 100 } })).is_err());
501        // delay without duration.
502        assert!(plugin(serde_json::json!({ "delay": { "percentage": 50 } })).is_err());
503        // percentage out of range.
504        assert!(plugin(serde_json::json!({
505            "abort": { "http_status": 500, "percentage": 101 }
506        }))
507        .is_err());
508        // invalid vars expression surfaces at load.
509        assert!(plugin(serde_json::json!({
510            "abort": { "http_status": 500, "vars": [[["arg_x", "bogus_op", "1"]]] }
511        }))
512        .is_err());
513    }
514}