mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
* feat(testing): add FaultInjector framework for StubLlm (#1220) Adds a configurable fault injection framework for testing retry, failover, and circuit breaker behavior. The FaultInjector attaches to StubLlm and provides per-call control over failure type, timing, and sequencing. Components: - FaultType: maps to LlmError variants (RequestFailed, RateLimited, AuthFailed, InvalidResponse, IoError, ContextLengthExceeded, SessionExpired) - FaultAction: Succeed, Fail(FaultType), Delay(Duration) - FaultMode: SequenceOnce (play then succeed), SequenceLoop (repeat forever), Random (seeded xorshift64 PRNG for reproducibility) - FaultInjector: thread-safe (AtomicU32 counter + Mutex RNG) Integration: - StubLlm gains optional fault_injector field via with_fault_injector() - When set, takes precedence over should_fail/error_kind - Backward compatible: existing StubLlm usage unchanged Closes #1220 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(testing): address review feedback on FaultInjector - Remove redundant .abs() in random fault comparison - Extract check_faults() helper to DRY up StubLlm methods - Guard xorshift seed=0 (fixed point) by mapping to 1 - Add StubLlm integration test (stub_llm_fault_injector_sequence) - Remove dead seed field from FaultMode::Random - Move pub mod fault_injection to top of mod.rs - Add Debug impl for FaultInjector - Add empty_sequence_always_succeeds test - Add random_seed_zero_does_not_always_fail test * fix(testing): address #1233 review -- seed-0 bug, reset(), Debug derive - Store seed in FaultMode::Random so reset() can re-init the RNG - Add reset() method for test reproducibility (re-seeds RNG, zeros counter) - Strengthen seed=0 regression test to 100 iterations with stricter assertion - Add reset_restores_random_rng_from_stored_seed test - Debug impl and empty_sequence test were already present from prior commit Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: re-trigger CI with latest changes Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: trigger new run with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(testing): address PR #1233 review -- error_rate validation and edge cases - Validate error_rate is in 0.0..=1.0 and not NaN (panics on invalid input) - Fix error_rate==1.0 edge case: use <= instead of < so 1.0 always fails - Add regression tests for error_rate validation (NaN, negative, >1.0) - Add tests for error_rate boundary values (0.0 never fails, 1.0 always fails) - Add delay action test using tokio::time::pause() for deterministic timing Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
433 lines
14 KiB
Rust
433 lines
14 KiB
Rust
//! Fault injection framework for testing retry, failover, and circuit breaker behavior.
|
|
//!
|
|
//! Provides [`FaultInjector`] which can be attached to [`StubLlm`](super::StubLlm) to
|
|
//! produce configurable error sequences, random failures, and delays.
|
|
//!
|
|
//! # Example
|
|
//!
|
|
//! ```rust,no_run
|
|
//! use ironclaw::testing::fault_injection::*;
|
|
//!
|
|
//! // Fail twice with transient errors, then succeed
|
|
//! let injector = FaultInjector::sequence([
|
|
//! FaultAction::Fail(FaultType::RequestFailed),
|
|
//! FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
|
|
//! FaultAction::Succeed,
|
|
//! ]);
|
|
//! ```
|
|
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use crate::llm::error::LlmError;
|
|
|
|
/// The type of fault to inject.
|
|
#[derive(Debug, Clone)]
|
|
pub enum FaultType {
|
|
/// Transient request failure (retryable).
|
|
RequestFailed,
|
|
/// Rate limited with optional retry-after duration.
|
|
RateLimited { retry_after: Option<Duration> },
|
|
/// Authentication failure (non-retryable).
|
|
AuthFailed,
|
|
/// Invalid response from provider (retryable).
|
|
InvalidResponse,
|
|
/// I/O error (retryable).
|
|
IoError,
|
|
/// Context length exceeded (non-retryable).
|
|
ContextLengthExceeded,
|
|
/// Session expired (transient for circuit breaker, not retryable).
|
|
SessionExpired,
|
|
}
|
|
|
|
impl FaultType {
|
|
/// Convert to the corresponding `LlmError`.
|
|
pub fn to_llm_error(&self, provider: &str) -> LlmError {
|
|
match self {
|
|
FaultType::RequestFailed => LlmError::RequestFailed {
|
|
provider: provider.to_string(),
|
|
reason: "injected fault: request failed".to_string(),
|
|
},
|
|
FaultType::RateLimited { retry_after } => LlmError::RateLimited {
|
|
provider: provider.to_string(),
|
|
retry_after: *retry_after,
|
|
},
|
|
FaultType::AuthFailed => LlmError::AuthFailed {
|
|
provider: provider.to_string(),
|
|
},
|
|
FaultType::InvalidResponse => LlmError::InvalidResponse {
|
|
provider: provider.to_string(),
|
|
reason: "injected fault: invalid response".to_string(),
|
|
},
|
|
FaultType::IoError => LlmError::Io(std::io::Error::new(
|
|
std::io::ErrorKind::ConnectionReset,
|
|
"injected fault: connection reset",
|
|
)),
|
|
FaultType::ContextLengthExceeded => LlmError::ContextLengthExceeded {
|
|
used: 100_000,
|
|
limit: 50_000,
|
|
},
|
|
FaultType::SessionExpired => LlmError::SessionExpired {
|
|
provider: provider.to_string(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Action to take on a given call.
|
|
#[derive(Debug, Clone)]
|
|
pub enum FaultAction {
|
|
/// Return a successful response.
|
|
Succeed,
|
|
/// Return an error of the given type.
|
|
Fail(FaultType),
|
|
/// Sleep for the given duration, then succeed.
|
|
Delay(Duration),
|
|
}
|
|
|
|
/// How the fault sequence is consumed.
|
|
#[derive(Debug, Clone)]
|
|
pub enum FaultMode {
|
|
/// Play the sequence once, then succeed for all subsequent calls.
|
|
SequenceOnce,
|
|
/// Loop the sequence forever.
|
|
SequenceLoop,
|
|
/// Fail randomly at the given rate (0.0 = never, 1.0 = always) with
|
|
/// the specified fault type. Uses a seeded RNG for reproducibility.
|
|
/// The seed is stored so that [`FaultInjector::reset()`] can re-initialize
|
|
/// the RNG for test reproducibility.
|
|
Random {
|
|
error_rate: f64,
|
|
fault: FaultType,
|
|
seed: u64,
|
|
},
|
|
}
|
|
|
|
/// A configurable fault injector for [`StubLlm`](super::StubLlm).
|
|
///
|
|
/// Thread-safe: uses atomic call counter and mutex-protected RNG.
|
|
pub struct FaultInjector {
|
|
actions: Vec<FaultAction>,
|
|
mode: FaultMode,
|
|
call_index: AtomicU32,
|
|
/// Seeded RNG for Random mode, behind Mutex for Sync.
|
|
rng_state: Mutex<u64>,
|
|
}
|
|
|
|
impl std::fmt::Debug for FaultInjector {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("FaultInjector")
|
|
.field("call_index", &self.call_index.load(Ordering::Relaxed))
|
|
.field("mode", &self.mode)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl FaultInjector {
|
|
/// Create a fault injector that plays actions once, then succeeds.
|
|
pub fn sequence(actions: impl IntoIterator<Item = FaultAction>) -> Self {
|
|
Self {
|
|
actions: actions.into_iter().collect(),
|
|
mode: FaultMode::SequenceOnce,
|
|
call_index: AtomicU32::new(0),
|
|
rng_state: Mutex::new(0),
|
|
}
|
|
}
|
|
|
|
/// Create a fault injector that loops the action sequence forever.
|
|
pub fn sequence_loop(actions: impl IntoIterator<Item = FaultAction>) -> Self {
|
|
Self {
|
|
actions: actions.into_iter().collect(),
|
|
mode: FaultMode::SequenceLoop,
|
|
call_index: AtomicU32::new(0),
|
|
rng_state: Mutex::new(0),
|
|
}
|
|
}
|
|
|
|
/// Create a fault injector with random failures at the given rate.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `error_rate` is not in `0.0..=1.0` or is NaN.
|
|
///
|
|
/// The seed is guarded against zero, which is a fixed point for xorshift.
|
|
pub fn random(error_rate: f64, fault: FaultType, seed: u64) -> Self {
|
|
assert!(
|
|
!error_rate.is_nan() && (0.0..=1.0).contains(&error_rate),
|
|
"error_rate must be in 0.0..=1.0 and not NaN, got {error_rate}"
|
|
);
|
|
let seed = if seed == 0 { 1 } else { seed };
|
|
Self {
|
|
actions: Vec::new(),
|
|
mode: FaultMode::Random {
|
|
error_rate,
|
|
fault,
|
|
seed,
|
|
},
|
|
call_index: AtomicU32::new(0),
|
|
rng_state: Mutex::new(seed),
|
|
}
|
|
}
|
|
|
|
/// Get the action for the next call.
|
|
pub fn next_action(&self) -> FaultAction {
|
|
let index = self.call_index.fetch_add(1, Ordering::Relaxed) as usize;
|
|
|
|
match &self.mode {
|
|
FaultMode::SequenceOnce => {
|
|
if index < self.actions.len() {
|
|
self.actions[index].clone()
|
|
} else {
|
|
FaultAction::Succeed
|
|
}
|
|
}
|
|
FaultMode::SequenceLoop => {
|
|
if self.actions.is_empty() {
|
|
FaultAction::Succeed
|
|
} else {
|
|
self.actions[index % self.actions.len()].clone()
|
|
}
|
|
}
|
|
FaultMode::Random {
|
|
error_rate, fault, ..
|
|
} => {
|
|
// Simple xorshift64 PRNG for reproducible randomness.
|
|
let random_val = {
|
|
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
|
|
*state ^= *state << 13;
|
|
*state ^= *state >> 7;
|
|
*state ^= *state << 17;
|
|
(*state as f64) / (u64::MAX as f64)
|
|
};
|
|
if random_val <= *error_rate {
|
|
FaultAction::Fail(fault.clone())
|
|
} else {
|
|
FaultAction::Succeed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the total number of calls made.
|
|
pub fn call_count(&self) -> u32 {
|
|
self.call_index.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Reset the injector to its initial state.
|
|
///
|
|
/// For `Random` mode, re-initializes the RNG from the stored seed,
|
|
/// which is useful for test reproducibility.
|
|
/// For all modes, resets the call counter to zero.
|
|
pub fn reset(&self) {
|
|
self.call_index.store(0, Ordering::Relaxed);
|
|
if let FaultMode::Random { seed, .. } = &self.mode {
|
|
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
|
|
*state = *seed;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sequence_once_plays_then_succeeds() {
|
|
let injector = FaultInjector::sequence([
|
|
FaultAction::Fail(FaultType::RequestFailed),
|
|
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
|
|
FaultAction::Succeed,
|
|
]);
|
|
|
|
// First two calls should fail
|
|
assert!(matches!(
|
|
injector.next_action(),
|
|
FaultAction::Fail(FaultType::RequestFailed)
|
|
));
|
|
assert!(matches!(
|
|
injector.next_action(),
|
|
FaultAction::Fail(FaultType::RateLimited { .. })
|
|
));
|
|
// Third call is explicit succeed
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
// Beyond sequence: implicit succeed
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
assert_eq!(injector.call_count(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn sequence_loop_repeats() {
|
|
let injector = FaultInjector::sequence_loop([
|
|
FaultAction::Fail(FaultType::RequestFailed),
|
|
FaultAction::Succeed,
|
|
]);
|
|
|
|
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
}
|
|
|
|
#[test]
|
|
fn random_mode_is_deterministic_with_seed() {
|
|
let injector1 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
let injector2 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
|
|
let results1: Vec<bool> = (0..20)
|
|
.map(|_| matches!(injector1.next_action(), FaultAction::Fail(_)))
|
|
.collect();
|
|
let results2: Vec<bool> = (0..20)
|
|
.map(|_| matches!(injector2.next_action(), FaultAction::Fail(_)))
|
|
.collect();
|
|
|
|
assert_eq!(results1, results2, "Same seed should produce same sequence");
|
|
}
|
|
|
|
#[test]
|
|
fn fault_type_produces_correct_llm_errors() {
|
|
let provider = "test-provider";
|
|
|
|
assert!(matches!(
|
|
FaultType::RequestFailed.to_llm_error(provider),
|
|
LlmError::RequestFailed { .. }
|
|
));
|
|
assert!(matches!(
|
|
FaultType::RateLimited {
|
|
retry_after: Some(Duration::from_secs(5))
|
|
}
|
|
.to_llm_error(provider),
|
|
LlmError::RateLimited { .. }
|
|
));
|
|
assert!(matches!(
|
|
FaultType::AuthFailed.to_llm_error(provider),
|
|
LlmError::AuthFailed { .. }
|
|
));
|
|
assert!(matches!(
|
|
FaultType::InvalidResponse.to_llm_error(provider),
|
|
LlmError::InvalidResponse { .. }
|
|
));
|
|
assert!(matches!(
|
|
FaultType::IoError.to_llm_error(provider),
|
|
LlmError::Io(_)
|
|
));
|
|
assert!(matches!(
|
|
FaultType::ContextLengthExceeded.to_llm_error(provider),
|
|
LlmError::ContextLengthExceeded { .. }
|
|
));
|
|
assert!(matches!(
|
|
FaultType::SessionExpired.to_llm_error(provider),
|
|
LlmError::SessionExpired { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn delay_action_exists() {
|
|
let injector = FaultInjector::sequence([FaultAction::Delay(Duration::from_millis(100))]);
|
|
assert!(matches!(injector.next_action(), FaultAction::Delay(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn random_seed_zero_does_not_always_fail() {
|
|
// seed=0 is a fixed point for xorshift; the constructor guards it to 1.
|
|
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 0);
|
|
let failures = (0..100)
|
|
.filter(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
.count();
|
|
assert!(failures < 100, "seed=0 must not produce stuck RNG");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_sequence_always_succeeds() {
|
|
let injector = FaultInjector::sequence([]);
|
|
for _ in 0..10 {
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reset_restores_random_rng_from_stored_seed() {
|
|
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
let run1: Vec<bool> = (0..20)
|
|
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
.collect();
|
|
|
|
injector.reset();
|
|
assert_eq!(injector.call_count(), 0);
|
|
|
|
let run2: Vec<bool> = (0..20)
|
|
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
.collect();
|
|
|
|
assert_eq!(run1, run2, "reset() should reproduce the same sequence");
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
|
|
fn random_rejects_error_rate_above_one() {
|
|
FaultInjector::random(1.5, FaultType::RequestFailed, 42);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
|
|
fn random_rejects_negative_error_rate() {
|
|
FaultInjector::random(-0.1, FaultType::RequestFailed, 42);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "error_rate must be in 0.0..=1.0 and not NaN")]
|
|
fn random_rejects_nan_error_rate() {
|
|
FaultInjector::random(f64::NAN, FaultType::RequestFailed, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn error_rate_one_always_fails() {
|
|
let injector = FaultInjector::random(1.0, FaultType::RequestFailed, 42);
|
|
for _ in 0..100 {
|
|
assert!(
|
|
matches!(injector.next_action(), FaultAction::Fail(_)),
|
|
"error_rate=1.0 must always produce failures"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn error_rate_zero_never_fails() {
|
|
let injector = FaultInjector::random(0.0, FaultType::RequestFailed, 42);
|
|
for _ in 0..100 {
|
|
assert!(
|
|
matches!(injector.next_action(), FaultAction::Succeed),
|
|
"error_rate=0.0 must never produce failures"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delay_action_pauses_execution() {
|
|
tokio::time::pause();
|
|
let injector = FaultInjector::sequence([
|
|
FaultAction::Delay(Duration::from_secs(10)),
|
|
FaultAction::Succeed,
|
|
]);
|
|
|
|
// First action is a delay
|
|
let action = injector.next_action();
|
|
assert!(matches!(action, FaultAction::Delay(d) if d == Duration::from_secs(10)));
|
|
|
|
// Simulate what StubLlm does: sleep then succeed
|
|
if let FaultAction::Delay(d) = action {
|
|
let start = tokio::time::Instant::now();
|
|
tokio::time::sleep(d).await;
|
|
let elapsed = start.elapsed();
|
|
assert!(
|
|
elapsed >= Duration::from_secs(10),
|
|
"delay should have paused for at least 10s, got {elapsed:?}"
|
|
);
|
|
}
|
|
|
|
// Next action succeeds
|
|
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
}
|
|
}
|