featherbit/plugins/native/
fault_injection.rs1use 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
27pub struct FaultInjectionPlugin {
34 abort: Option<AbortRule>,
35 delay: Option<DelayRule>,
36}
37
38struct AbortRule {
39 http_status: u16,
40 body: Option<Template>,
44 headers: Vec<(String, Template)>,
46 percentage: Option<u8>,
47 vars: Option<Vec<Expr>>,
49}
50
51struct DelayRule {
52 duration: Duration,
53 percentage: Option<u8>,
54 vars: Option<Vec<Expr>>,
55}
56
57fn 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
69fn sample_hit(percentage: Option<u8>) -> bool {
72 match percentage {
73 None => true,
74 Some(p) => roll_percent() < p,
75 }
76}
77
78fn 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
87fn 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 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 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
132fn 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
149fn 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; }
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 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 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 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 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 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 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 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 assert!(plugin(serde_json::json!({})).is_err());
497 assert!(plugin(serde_json::json!({ "abort": { "body": "x" } })).is_err());
499 assert!(plugin(serde_json::json!({ "abort": { "http_status": 100 } })).is_err());
501 assert!(plugin(serde_json::json!({ "delay": { "percentage": 50 } })).is_err());
503 assert!(plugin(serde_json::json!({
505 "abort": { "http_status": 500, "percentage": 101 }
506 }))
507 .is_err());
508 assert!(plugin(serde_json::json!({
510 "abort": { "http_status": 500, "vars": [[["arg_x", "bogus_op", "1"]]] }
511 }))
512 .is_err());
513 }
514}