use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum FaultType {
Delay { ms: u64 },
Error { status: u16 },
Timeout,
Corrupt,
}
impl fmt::Display for FaultType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Delay { ms } => write!(f, "delay({ms}ms)"),
Self::Error { status } => write!(f, "error({status})"),
Self::Timeout => write!(f, "timeout"),
Self::Corrupt => write!(f, "corrupt"),
}
}
}
#[derive(Debug, Clone)]
pub struct FaultRule {
pub fault_type: FaultType,
pub rate: f64,
}
#[derive(Debug, Clone, Default)]
pub struct FaultConfig {
pub rules: Vec<FaultRule>,
pub enabled: bool,
}
impl FaultConfig {
#[must_use]
pub fn new() -> Self {
Self {
rules: Vec::new(),
enabled: true,
}
}
#[must_use]
pub fn add(mut self, fault_type: FaultType, rate: f64) -> Self {
self.rules.push(FaultRule {
fault_type,
rate: rate.clamp(0.0, 1.0),
});
self
}
#[must_use]
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug)]
pub struct FaultInjector {
config: FaultConfig,
}
impl FaultInjector {
pub fn new(config: FaultConfig) -> Self {
Self { config }
}
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
pub fn check(&self, request_id: u64) -> Option<&FaultType> {
if !self.config.enabled {
return None;
}
for (i, rule) in self.config.rules.iter().enumerate() {
let hash = Self::hash(request_id, i as u64);
let threshold = (rule.rate * u64::MAX as f64) as u64;
if hash < threshold {
return Some(&rule.fault_type);
}
}
None
}
pub fn config(&self) -> &FaultConfig {
&self.config
}
fn hash(request_id: u64, rule_index: u64) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for byte in request_id.to_le_bytes() {
h ^= u64::from(byte);
h = h.wrapping_mul(0x100000001b3);
}
for byte in rule_index.to_le_bytes() {
h ^= u64::from(byte);
h = h.wrapping_mul(0x100000001b3);
}
h
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::float_cmp)]
fn fault_config_builder() {
let config = FaultConfig::new()
.add(FaultType::Delay { ms: 100 }, 0.5)
.add(FaultType::Error { status: 500 }, 0.1);
assert!(config.enabled);
assert_eq!(config.rules.len(), 2);
assert_eq!(config.rules[0].rate, 0.5);
}
#[test]
fn fault_injector_disabled() {
let config = FaultConfig::new()
.add(FaultType::Error { status: 500 }, 1.0) .enabled(false);
let injector = FaultInjector::new(config);
assert!(injector.check(1).is_none());
}
#[test]
fn fault_injector_always_fires_at_rate_1() {
let config = FaultConfig::new().add(FaultType::Timeout, 1.0);
let injector = FaultInjector::new(config);
for id in 0..100 {
assert_eq!(injector.check(id), Some(&FaultType::Timeout));
}
}
#[test]
fn fault_injector_never_fires_at_rate_0() {
let config = FaultConfig::new().add(FaultType::Timeout, 0.0);
let injector = FaultInjector::new(config);
for id in 0..100 {
assert!(injector.check(id).is_none());
}
}
#[test]
fn fault_injector_deterministic() {
let config = FaultConfig::new().add(FaultType::Error { status: 503 }, 0.5);
let injector = FaultInjector::new(config);
let result1 = injector.check(42);
let result2 = injector.check(42);
assert_eq!(result1, result2);
}
#[test]
fn fault_injector_partial_rate() {
let config = FaultConfig::new().add(FaultType::Delay { ms: 50 }, 0.5);
let injector = FaultInjector::new(config);
let mut fired = 0;
for id in 0..1000 {
if injector.check(id).is_some() {
fired += 1;
}
}
assert!(fired > 300 && fired < 700, "fired {fired} out of 1000");
}
#[test]
fn fault_type_display() {
assert_eq!(FaultType::Delay { ms: 100 }.to_string(), "delay(100ms)");
assert_eq!(FaultType::Error { status: 500 }.to_string(), "error(500)");
assert_eq!(FaultType::Timeout.to_string(), "timeout");
assert_eq!(FaultType::Corrupt.to_string(), "corrupt");
}
#[test]
#[allow(clippy::float_cmp)]
fn fault_config_rate_clamped() {
let config = FaultConfig::new().add(FaultType::Timeout, 2.0);
assert_eq!(config.rules[0].rate, 1.0);
let config = FaultConfig::new().add(FaultType::Timeout, -1.0);
assert_eq!(config.rules[0].rate, 0.0);
}
}