Skip to main content

fastapi_core/
fault.rs

1//! Fault injection for resilience testing.
2//!
3//! Provides configurable fault injection to test error handling and
4//! system resilience under failure conditions.
5//!
6//! # Example
7//!
8//! ```
9//! use fastapi_core::fault::{FaultConfig, FaultInjector, FaultType};
10//!
11//! let config = FaultConfig::new()
12//!     .add(FaultType::Delay { ms: 100 }, 0.1)     // 10% chance of 100ms delay
13//!     .add(FaultType::Error { status: 500 }, 0.05); // 5% chance of 500 error
14//!
15//! let injector = FaultInjector::new(config);
16//! let fault = injector.check(42); // deterministic based on request ID
17//! ```
18
19use std::fmt;
20
21/// Types of faults that can be injected.
22#[derive(Debug, Clone, PartialEq)]
23pub enum FaultType {
24    /// Add latency to the response.
25    Delay { ms: u64 },
26    /// Return an error response.
27    Error { status: u16 },
28    /// Simulate a timeout (return 504).
29    Timeout,
30    /// Corrupt the response body (replace with garbage).
31    Corrupt,
32}
33
34impl fmt::Display for FaultType {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Delay { ms } => write!(f, "delay({ms}ms)"),
38            Self::Error { status } => write!(f, "error({status})"),
39            Self::Timeout => write!(f, "timeout"),
40            Self::Corrupt => write!(f, "corrupt"),
41        }
42    }
43}
44
45/// A single fault rule: a fault type and the probability of it firing (0.0–1.0).
46#[derive(Debug, Clone)]
47pub struct FaultRule {
48    pub fault_type: FaultType,
49    /// Probability [0.0, 1.0] that this fault fires on a given request.
50    pub rate: f64,
51}
52
53/// Configuration for fault injection.
54#[derive(Debug, Clone, Default)]
55pub struct FaultConfig {
56    /// Fault rules evaluated in order.
57    pub rules: Vec<FaultRule>,
58    /// Whether fault injection is enabled.
59    pub enabled: bool,
60}
61
62impl FaultConfig {
63    /// Create a new fault config (enabled by default).
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            rules: Vec::new(),
68            enabled: true,
69        }
70    }
71
72    /// Add a fault rule.
73    #[must_use]
74    pub fn add(mut self, fault_type: FaultType, rate: f64) -> Self {
75        self.rules.push(FaultRule {
76            fault_type,
77            rate: rate.clamp(0.0, 1.0),
78        });
79        self
80    }
81
82    /// Enable or disable fault injection.
83    #[must_use]
84    pub fn enabled(mut self, enabled: bool) -> Self {
85        self.enabled = enabled;
86        self
87    }
88}
89
90/// Deterministic fault injector for testing.
91///
92/// Uses a simple hash of the request ID to decide whether to inject
93/// a fault, making tests reproducible.
94#[derive(Debug)]
95pub struct FaultInjector {
96    config: FaultConfig,
97}
98
99impl FaultInjector {
100    /// Create a new injector with the given config.
101    pub fn new(config: FaultConfig) -> Self {
102        Self { config }
103    }
104
105    /// Check if a fault should be injected for the given request ID.
106    ///
107    /// Returns the first matching fault, or `None` if no fault fires.
108    /// Uses deterministic hashing so the same request ID always produces
109    /// the same result.
110    #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
111    pub fn check(&self, request_id: u64) -> Option<&FaultType> {
112        if !self.config.enabled {
113            return None;
114        }
115        for (i, rule) in self.config.rules.iter().enumerate() {
116            let hash = Self::hash(request_id, i as u64);
117            let threshold = (rule.rate * u64::MAX as f64) as u64;
118            if hash < threshold {
119                return Some(&rule.fault_type);
120            }
121        }
122        None
123    }
124
125    /// Returns the config.
126    pub fn config(&self) -> &FaultConfig {
127        &self.config
128    }
129
130    /// Simple deterministic hash for fault decisions.
131    fn hash(request_id: u64, rule_index: u64) -> u64 {
132        // FNV-1a inspired mixing
133        let mut h: u64 = 0xcbf29ce484222325;
134        for byte in request_id.to_le_bytes() {
135            h ^= u64::from(byte);
136            h = h.wrapping_mul(0x100000001b3);
137        }
138        for byte in rule_index.to_le_bytes() {
139            h ^= u64::from(byte);
140            h = h.wrapping_mul(0x100000001b3);
141        }
142        h
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    #[allow(clippy::float_cmp)]
152    fn fault_config_builder() {
153        let config = FaultConfig::new()
154            .add(FaultType::Delay { ms: 100 }, 0.5)
155            .add(FaultType::Error { status: 500 }, 0.1);
156        assert!(config.enabled);
157        assert_eq!(config.rules.len(), 2);
158        assert_eq!(config.rules[0].rate, 0.5);
159    }
160
161    #[test]
162    fn fault_injector_disabled() {
163        let config = FaultConfig::new()
164            .add(FaultType::Error { status: 500 }, 1.0) // 100% rate
165            .enabled(false);
166        let injector = FaultInjector::new(config);
167        assert!(injector.check(1).is_none());
168    }
169
170    #[test]
171    fn fault_injector_always_fires_at_rate_1() {
172        let config = FaultConfig::new().add(FaultType::Timeout, 1.0);
173        let injector = FaultInjector::new(config);
174        // With rate 1.0, should fire for all request IDs
175        for id in 0..100 {
176            assert_eq!(injector.check(id), Some(&FaultType::Timeout));
177        }
178    }
179
180    #[test]
181    fn fault_injector_never_fires_at_rate_0() {
182        let config = FaultConfig::new().add(FaultType::Timeout, 0.0);
183        let injector = FaultInjector::new(config);
184        for id in 0..100 {
185            assert!(injector.check(id).is_none());
186        }
187    }
188
189    #[test]
190    fn fault_injector_deterministic() {
191        let config = FaultConfig::new().add(FaultType::Error { status: 503 }, 0.5);
192        let injector = FaultInjector::new(config);
193        // Same request ID should always produce same result
194        let result1 = injector.check(42);
195        let result2 = injector.check(42);
196        assert_eq!(result1, result2);
197    }
198
199    #[test]
200    fn fault_injector_partial_rate() {
201        let config = FaultConfig::new().add(FaultType::Delay { ms: 50 }, 0.5);
202        let injector = FaultInjector::new(config);
203        let mut fired = 0;
204        for id in 0..1000 {
205            if injector.check(id).is_some() {
206                fired += 1;
207            }
208        }
209        // With rate 0.5, expect roughly 500 fires out of 1000
210        assert!(fired > 300 && fired < 700, "fired {fired} out of 1000");
211    }
212
213    #[test]
214    fn fault_type_display() {
215        assert_eq!(FaultType::Delay { ms: 100 }.to_string(), "delay(100ms)");
216        assert_eq!(FaultType::Error { status: 500 }.to_string(), "error(500)");
217        assert_eq!(FaultType::Timeout.to_string(), "timeout");
218        assert_eq!(FaultType::Corrupt.to_string(), "corrupt");
219    }
220
221    #[test]
222    #[allow(clippy::float_cmp)]
223    fn fault_config_rate_clamped() {
224        let config = FaultConfig::new().add(FaultType::Timeout, 2.0);
225        assert_eq!(config.rules[0].rate, 1.0);
226        let config = FaultConfig::new().add(FaultType::Timeout, -1.0);
227        assert_eq!(config.rules[0].rate, 0.0);
228    }
229}