1use std::fmt;
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum FaultType {
24 Delay { ms: u64 },
26 Error { status: u16 },
28 Timeout,
30 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#[derive(Debug, Clone)]
47pub struct FaultRule {
48 pub fault_type: FaultType,
49 pub rate: f64,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct FaultConfig {
56 pub rules: Vec<FaultRule>,
58 pub enabled: bool,
60}
61
62impl FaultConfig {
63 #[must_use]
65 pub fn new() -> Self {
66 Self {
67 rules: Vec::new(),
68 enabled: true,
69 }
70 }
71
72 #[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 #[must_use]
84 pub fn enabled(mut self, enabled: bool) -> Self {
85 self.enabled = enabled;
86 self
87 }
88}
89
90#[derive(Debug)]
95pub struct FaultInjector {
96 config: FaultConfig,
97}
98
99impl FaultInjector {
100 pub fn new(config: FaultConfig) -> Self {
102 Self { config }
103 }
104
105 #[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 pub fn config(&self) -> &FaultConfig {
127 &self.config
128 }
129
130 fn hash(request_id: u64, rule_index: u64) -> u64 {
132 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) .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 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 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 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}