featherbit/plugins/native/
fault_injection.rs1use async_trait::async_trait;
19use bytes::Bytes;
20use std::collections::HashMap;
21use std::time::Duration;
22
23use crate::context::{Context, GatewayError};
24use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
25use crate::vars::{interpolate, Expr};
26
27pub struct FaultInjectionPlugin {
33 abort: Option<AbortRule>,
34 delay: Option<DelayRule>,
35}
36
37struct AbortRule {
38 http_status: u16,
39 body: Option<String>,
41 headers: Vec<(String, String)>,
43 percentage: Option<u8>,
44 vars: Option<Vec<Expr>>,
46}
47
48struct DelayRule {
49 duration: Duration,
50 percentage: Option<u8>,
51 vars: Option<Vec<Expr>>,
52}
53
54fn roll_percent() -> u8 {
61 use std::collections::hash_map::RandomState;
62 use std::hash::{BuildHasher, Hasher};
63 (RandomState::new().build_hasher().finish() % 100) as u8
64}
65
66fn sample_hit(percentage: Option<u8>) -> bool {
69 match percentage {
70 None => true,
71 Some(p) => roll_percent() < p,
72 }
73}
74
75fn vars_match(vars: &Option<Vec<Expr>>, ctx: &Context) -> bool {
78 match vars {
79 None => true,
80 Some(exprs) => exprs.iter().any(|e| e.eval(ctx)),
81 }
82}
83
84fn parse_or_vars(v: &serde_json::Value, field: &str) -> Result<Vec<Expr>, String> {
92 let items = v
93 .as_array()
94 .ok_or_else(|| format!("{field}.vars must be an array"))?;
95
96 let first_str = items
99 .first()
100 .and_then(|i| i.as_array())
101 .and_then(|a| a.first())
102 .and_then(|f| f.as_str());
103 if let Some(s) = first_str {
104 if !s.eq_ignore_ascii_case("and") && !s.eq_ignore_ascii_case("or") {
105 let expr = Expr::parse(v).map_err(|e| format!("{field}.vars: {e}"))?;
106 return Ok(vec![expr]);
107 }
108 }
109
110 items
111 .iter()
112 .map(|item| {
113 let starts_with_op = item
114 .as_array()
115 .and_then(|a| a.first())
116 .and_then(|f| f.as_str())
117 .is_some();
118 let parsed = if starts_with_op {
119 Expr::parse(&serde_json::Value::Array(vec![item.clone()]))
121 } else {
122 Expr::parse(item)
123 };
124 parsed.map_err(|e| format!("{field}.vars: {e}"))
125 })
126 .collect()
127}
128
129fn parse_percentage(
131 obj: &serde_json::Map<String, serde_json::Value>,
132 field: &str,
133) -> Result<Option<u8>, String> {
134 match obj.get("percentage") {
135 None => Ok(None),
136 Some(v) => {
137 let n = v
138 .as_u64()
139 .filter(|n| *n <= 100)
140 .ok_or_else(|| format!("{field}.percentage must be an integer 0-100"))?;
141 Ok(Some(n as u8))
142 }
143 }
144}
145
146fn parse_headers(v: &serde_json::Value, field: &str) -> Result<Vec<(String, String)>, String> {
150 let scalar = |v: &serde_json::Value| -> Option<String> {
151 match v {
152 serde_json::Value::String(s) => Some(s.clone()),
153 serde_json::Value::Number(n) => Some(n.to_string()),
154 serde_json::Value::Bool(b) => Some(b.to_string()),
155 _ => None,
156 }
157 };
158 match v {
159 serde_json::Value::Object(m) => m
160 .iter()
161 .map(|(k, v)| {
162 scalar(v)
163 .map(|s| (k.to_lowercase(), s))
164 .ok_or_else(|| format!("{field}['{k}'] must be a scalar value"))
165 })
166 .collect(),
167 serde_json::Value::Array(items) => {
168 let mut out = Vec::new();
169 for item in items {
170 let obj = item.as_object().ok_or_else(|| {
171 format!("{field} entries must be objects with 'name' and 'value'")
172 })?;
173 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
174 if name.trim().is_empty() {
175 continue; }
177 let value = match obj.get("value") {
178 None => String::new(),
179 Some(v) => scalar(v)
180 .ok_or_else(|| format!("{field}['{name}'] must be a scalar value"))?,
181 };
182 out.push((name.to_lowercase(), value));
183 }
184 Ok(out)
185 }
186 _ => Err(format!(
187 "{field} must be a map of name: value or a list of {{name, value}} objects"
188 )),
189 }
190}
191
192impl FaultInjectionPlugin {
193 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
224 let abort = match config.get("abort") {
225 None => None,
226 Some(v) => {
227 let obj = v.as_object().ok_or("abort must be an object")?;
228 let http_status = obj
229 .get("http_status")
230 .and_then(|v| v.as_u64())
231 .filter(|n| (200..=599).contains(n))
232 .ok_or("abort.http_status is required and must be an integer >= 200")?
233 as u16;
234 let body = match obj.get("body") {
235 None => None,
236 Some(v) => Some(v.as_str().ok_or("abort.body must be a string")?.to_string()),
237 };
238 let headers = match obj.get("headers") {
239 None => Vec::new(),
240 Some(v) => parse_headers(v, "abort.headers")?,
241 };
242 let percentage = parse_percentage(obj, "abort")?;
243 let vars = match obj.get("vars") {
244 None => None,
245 Some(v) => Some(parse_or_vars(v, "abort")?),
246 };
247 Some(AbortRule {
248 http_status,
249 body,
250 headers,
251 percentage,
252 vars,
253 })
254 }
255 };
256
257 let delay = match config.get("delay") {
258 None => None,
259 Some(v) => {
260 let obj = v.as_object().ok_or("delay must be an object")?;
261 let duration = obj
262 .get("duration")
263 .and_then(|v| v.as_f64())
264 .filter(|d| *d >= 0.0 && d.is_finite())
265 .ok_or(
266 "delay.duration is required and must be a non-negative number of seconds",
267 )?;
268 let percentage = parse_percentage(obj, "delay")?;
269 let vars = match obj.get("vars") {
270 None => None,
271 Some(v) => Some(parse_or_vars(v, "delay")?),
272 };
273 Some(DelayRule {
274 duration: Duration::from_secs_f64(duration),
275 percentage,
276 vars,
277 })
278 }
279 };
280
281 if abort.is_none() && delay.is_none() {
282 return Err("fault-injection requires at least one of 'abort' or 'delay'".to_string());
283 }
284
285 Ok(Self { abort, delay })
286 }
287}
288
289#[async_trait]
290impl Plugin for FaultInjectionPlugin {
291 fn plugin_type(&self) -> &str {
292 "fault-injection"
293 }
294
295 async fn execute(
296 &self,
297 mut ctx: Context,
298 _named_inputs: &HashMap<String, serde_json::Value>,
299 ) -> PluginResult {
300 if let Some(delay) = &self.delay {
301 if sample_hit(delay.percentage) && vars_match(&delay.vars, &ctx) {
302 tokio::time::sleep(delay.duration).await;
303 }
304 }
305
306 if let Some(abort) = &self.abort {
307 if sample_hit(abort.percentage) && vars_match(&abort.vars, &ctx) {
308 let body = abort
310 .body
311 .as_ref()
312 .map(|b| interpolate(&ctx, b))
313 .unwrap_or_default();
314 let headers: Vec<(String, String)> = abort
315 .headers
316 .iter()
317 .map(|(name, tmpl)| (name.clone(), interpolate(&ctx, tmpl)))
318 .collect();
319
320 ctx.response.status_code = abort.http_status;
321 ctx.response.body = Bytes::from(body);
322 for (name, value) in headers {
323 ctx.response.headers.insert(name, vec![value]);
324 }
325
326 return Err(PluginExecutionError {
327 context: ctx,
328 error: GatewayError {
329 node_id: String::new(),
330 code: "FAULT_INJECTED".to_string(),
331 message: format!("fault injected: abort with status {}", abort.http_status),
332 metadata: HashMap::new(),
333 },
334 });
335 }
336 }
337
338 Ok(PluginOutput {
339 context: ctx,
340 named_outputs: HashMap::new(),
341 })
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
349 use std::time::Instant;
350
351 fn test_ctx() -> Context {
352 let mut query = HashMap::new();
353 query.insert("name".to_string(), vec!["jack".to_string()]);
354 Context {
355 request: GatewayRequest {
356 method: "GET".to_string(),
357 path: "/api/users".to_string(),
358 host: "example.com".to_string(),
359 scheme: "http".to_string(),
360 headers: HashMap::new(),
361 query_params: query,
362 body: Bytes::new(),
363 remote_addr: "10.1.2.3:44321".to_string(),
364 protocol: Protocol::Http1,
365 },
366 response: GatewayResponse {
367 status_code: 0,
368 headers: HashMap::new(),
369 body: Bytes::new(),
370 },
371 message: HashMap::new(),
372 errors: Vec::new(),
373 }
374 }
375
376 fn plugin(config: serde_json::Value) -> Result<FaultInjectionPlugin, String> {
377 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
378 FaultInjectionPlugin::from_config(&map)
379 }
380
381 #[tokio::test]
382 async fn test_abort_prepares_response_and_errors() {
383 let p = plugin(serde_json::json!({
384 "abort": {
385 "http_status": 503,
386 "body": "injected for $uri",
387 "headers": { "X-Fault": "yes" }
388 }
389 }))
390 .unwrap();
391
392 let err = p.execute(test_ctx(), &HashMap::new()).await.unwrap_err();
393 assert_eq!(err.error.code, "FAULT_INJECTED");
394 let ctx = err.context;
395 assert_eq!(ctx.response.status_code, 503);
396 assert_eq!(ctx.response.body, Bytes::from("injected for /api/users"));
397 assert_eq!(
398 ctx.response.headers.get("x-fault"),
399 Some(&vec!["yes".to_string()])
400 );
401 }
402
403 #[tokio::test]
404 async fn test_abort_vars_gate() {
405 let p = plugin(serde_json::json!({
407 "abort": {
408 "http_status": 500,
409 "vars": [
410 [["arg_name", "==", "nope"]],
411 [["arg_name", "==", "jack"]]
412 ]
413 }
414 }))
415 .unwrap();
416 assert!(p.execute(test_ctx(), &HashMap::new()).await.is_err());
417
418 let p = plugin(serde_json::json!({
420 "abort": {
421 "http_status": 500,
422 "vars": [[["arg_name", "==", "jill"]]]
423 }
424 }))
425 .unwrap();
426 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
427 assert_eq!(out.context.response.status_code, 0);
428 }
429
430 #[tokio::test]
431 async fn test_flat_vars_shape_accepted() {
432 let p = plugin(serde_json::json!({
433 "abort": {
434 "http_status": 500,
435 "vars": [["arg_name", "==", "jack"]]
436 }
437 }))
438 .unwrap();
439 assert!(p.execute(test_ctx(), &HashMap::new()).await.is_err());
440 }
441
442 #[tokio::test]
443 async fn test_percentage_edges() {
444 let p0 = plugin(serde_json::json!({
446 "abort": { "http_status": 500, "percentage": 0 }
447 }))
448 .unwrap();
449 let p100 = plugin(serde_json::json!({
450 "abort": { "http_status": 500, "percentage": 100 }
451 }))
452 .unwrap();
453 for _ in 0..20 {
454 assert!(p0.execute(test_ctx(), &HashMap::new()).await.is_ok());
455 assert!(p100.execute(test_ctx(), &HashMap::new()).await.is_err());
456 }
457 }
458
459 #[tokio::test]
460 async fn test_delay_sleeps() {
461 let p = plugin(serde_json::json!({
462 "delay": { "duration": 0.05 }
463 }))
464 .unwrap();
465 let start = Instant::now();
466 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
467 assert!(start.elapsed() >= Duration::from_millis(45));
468 assert_eq!(out.context.response.status_code, 0);
469 }
470
471 #[tokio::test]
472 async fn test_delay_vars_gate_skips_sleep() {
473 let p = plugin(serde_json::json!({
474 "delay": {
475 "duration": 5.0,
476 "vars": [[["arg_name", "==", "nobody"]]]
477 }
478 }))
479 .unwrap();
480 let start = Instant::now();
481 p.execute(test_ctx(), &HashMap::new()).await.unwrap();
482 assert!(start.elapsed() < Duration::from_secs(1));
483 }
484
485 #[test]
486 fn test_config_errors() {
487 assert!(plugin(serde_json::json!({})).is_err());
489 assert!(plugin(serde_json::json!({ "abort": { "body": "x" } })).is_err());
491 assert!(plugin(serde_json::json!({ "abort": { "http_status": 100 } })).is_err());
493 assert!(plugin(serde_json::json!({ "delay": { "percentage": 50 } })).is_err());
495 assert!(plugin(serde_json::json!({
497 "abort": { "http_status": 500, "percentage": 101 }
498 }))
499 .is_err());
500 assert!(plugin(serde_json::json!({
502 "abort": { "http_status": 500, "vars": [[["arg_x", "bogus_op", "1"]]] }
503 }))
504 .is_err());
505 }
506}