//! Multi-provider LLM failover. //! //! Wraps multiple LlmProvider instances and tries each in sequence //! until one succeeds. Transparent to callers --- same LlmProvider trait. //! //! Providers that fail repeatedly are temporarily placed in cooldown //! so subsequent requests skip them, reducing latency when a provider //! is known to be down. Cooldown state is lock-free (atomics only). use std::collections::HashMap; use std::future::Future; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use async_trait::async_trait; use rust_decimal::Decimal; use crate::llm::error::LlmError; use crate::llm::provider::{ CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest, ToolCompletionResponse, }; use crate::llm::retry::is_retryable; /// Configuration for per-provider cooldown behavior. /// /// When a provider accumulates `failure_threshold` consecutive retryable /// failures, it enters cooldown for `cooldown_duration`. During cooldown /// the provider is skipped (unless *all* providers are in cooldown, in /// which case the oldest-cooled one is tried). #[derive(Debug, Clone)] pub struct CooldownConfig { /// How long a provider stays in cooldown after exceeding the threshold. pub cooldown_duration: Duration, /// Number of consecutive retryable failures before cooldown activates. pub failure_threshold: u32, } impl Default for CooldownConfig { fn default() -> Self { Self { cooldown_duration: Duration::from_secs(300), failure_threshold: 3, } } } /// Per-provider cooldown state, entirely lock-free. /// /// All atomic operations use `Relaxed` ordering — consistent with the /// existing `last_used` field. Stale reads are harmless: the worst case /// is one extra attempt against a provider that just entered cooldown. struct ProviderCooldown { /// Consecutive retryable failures. Reset to 0 on success. failure_count: AtomicU32, /// Nanoseconds since `epoch` when cooldown was activated. /// 0 means the provider is NOT in cooldown. cooldown_activated_nanos: AtomicU64, } impl ProviderCooldown { fn new() -> Self { Self { failure_count: AtomicU32::new(0), cooldown_activated_nanos: AtomicU64::new(0), } } /// Check whether the provider is currently in cooldown. fn is_in_cooldown(&self, now_nanos: u64, cooldown_nanos: u64) -> bool { let activated = self.cooldown_activated_nanos.load(Ordering::Relaxed); activated != 0 && now_nanos.saturating_sub(activated) < cooldown_nanos } /// Record a retryable failure. Returns `true` if the threshold was /// just reached (caller should activate cooldown). fn record_failure(&self, threshold: u32) -> bool { let prev = self.failure_count.fetch_add(1, Ordering::Relaxed); prev + 1 >= threshold } /// Activate cooldown at the given timestamp. fn activate_cooldown(&self, now_nanos: u64) { // Ensure 0 remains a safe "not in cooldown" sentinel. self.cooldown_activated_nanos .store(now_nanos.max(1), Ordering::Relaxed); } /// Reset failure count and clear cooldown (called on success). fn reset(&self) { self.failure_count.store(0, Ordering::Relaxed); self.cooldown_activated_nanos.store(0, Ordering::Relaxed); } } /// An LLM provider that wraps multiple providers and tries each in sequence /// on transient failures. /// /// The first provider in the list is the primary. If it fails with a retryable /// error, the next provider is tried, and so on. Non-retryable errors /// (e.g. `AuthFailed`, `ContextLengthExceeded`) propagate immediately. /// /// Providers that repeatedly fail with retryable errors are temporarily /// placed in cooldown and skipped, reducing latency. pub struct FailoverProvider { providers: Vec>, /// Index of the provider that last handled a request successfully. /// Used by `model_name()` and `cost_per_token()` so downstream cost /// tracking reflects the provider that actually served the request. last_used: AtomicUsize, /// Per-provider cooldown tracking (same length as `providers`). cooldowns: Vec, /// Reference instant for computing elapsed nanos. Shared across all /// cooldown timestamps so they are comparable. epoch: Instant, /// Cooldown configuration. cooldown_config: CooldownConfig, /// Request-scoped provider index keyed by Tokio task ID. /// /// This allows `effective_model_name()` to report the provider that handled /// the *current* request, even when other concurrent requests update /// `last_used`. provider_for_task: Mutex>, } impl FailoverProvider { /// Create a new failover provider with default cooldown settings. /// /// Returns an error if `providers` is empty. pub fn new(providers: Vec>) -> Result { Self::with_cooldown(providers, CooldownConfig::default()) } /// Create a new failover provider with explicit cooldown configuration. /// /// Returns an error if `providers` is empty. pub fn with_cooldown( providers: Vec>, cooldown_config: CooldownConfig, ) -> Result { if providers.is_empty() { return Err(LlmError::RequestFailed { provider: "failover".to_string(), reason: "FailoverProvider requires at least one provider".to_string(), }); } let cooldowns = (0..providers.len()) .map(|_| ProviderCooldown::new()) .collect(); Ok(Self { providers, last_used: AtomicUsize::new(0), cooldowns, epoch: Instant::now(), cooldown_config, provider_for_task: Mutex::new(HashMap::new()), }) } /// Nanoseconds elapsed since `self.epoch`. /// /// Truncates `u128` → `u64` (wraps after ~584 years of continuous /// uptime). Acceptable because `epoch` is set at construction time. fn now_nanos(&self) -> u64 { self.epoch.elapsed().as_nanos() as u64 } /// Current Tokio task ID if available. fn current_task_id() -> Option { tokio::task::try_id() } /// Bind the selected provider index to the current task. fn bind_provider_to_current_task(&self, provider_idx: usize) { let Some(task_id) = Self::current_task_id() else { return; }; if let Ok(mut guard) = self.provider_for_task.lock() { guard.insert(task_id, provider_idx); } } /// Take and remove the provider index bound to the current task. fn take_bound_provider_for_current_task(&self) -> Option { let task_id = Self::current_task_id()?; self.provider_for_task .lock() .ok() .and_then(|mut guard| guard.remove(&task_id)) } /// Try each provider in sequence until one succeeds or all fail. /// /// Providers in cooldown are skipped unless *all* providers are in /// cooldown, in which case the one with the oldest cooldown timestamp /// (most likely to have recovered) is tried. async fn try_providers(&self, mut call: F) -> Result<(usize, T), LlmError> where F: FnMut(Arc) -> Fut, Fut: Future>, { let now_nanos = self.now_nanos(); let cooldown_nanos = self.cooldown_config.cooldown_duration.as_nanos() as u64; // Partition providers into available and cooled-down. let (mut available, cooled_down): (Vec, Vec) = (0..self.providers.len()) .partition(|&i| !self.cooldowns[i].is_in_cooldown(now_nanos, cooldown_nanos)); // Log skipped providers. for &i in &cooled_down { tracing::info!( provider = %self.providers[i].model_name(), "Skipping provider (in cooldown)" ); } // Never skip ALL providers: if every provider is in cooldown, pick // the one with the oldest cooldown activation (most likely recovered). if available.is_empty() { let oldest = (0..self.providers.len()) .min_by_key(|&i| { self.cooldowns[i] .cooldown_activated_nanos .load(Ordering::Relaxed) }) .ok_or_else(|| LlmError::RequestFailed { provider: "failover".to_string(), reason: "FailoverProvider requires at least one provider".to_string(), })?; tracing::info!( provider = %self.providers[oldest].model_name(), "All providers in cooldown, trying oldest-cooled provider" ); available.push(oldest); } let mut last_error: Option = None; for (pos, &i) in available.iter().enumerate() { let provider = &self.providers[i]; let result = call(Arc::clone(provider)).await; match result { Ok(response) => { self.last_used.store(i, Ordering::Relaxed); self.cooldowns[i].reset(); return Ok((i, response)); } Err(err) => { if !is_retryable(&err) { return Err(err); } // Increment failure count; activate cooldown if threshold reached. if self.cooldowns[i].record_failure(self.cooldown_config.failure_threshold) { let nanos = self.now_nanos(); self.cooldowns[i].activate_cooldown(nanos); tracing::warn!( provider = %provider.model_name(), threshold = self.cooldown_config.failure_threshold, cooldown_secs = self.cooldown_config.cooldown_duration.as_secs(), "Provider entered cooldown after repeated failures" ); } if pos + 1 < available.len() { let next_i = available[pos + 1]; tracing::warn!( provider = %provider.model_name(), error = %err, next_provider = %self.providers[next_i].model_name(), "Provider failed with retryable error, trying next provider" ); } last_error = Some(err); } } } Err(last_error.unwrap_or_else(|| LlmError::RequestFailed { provider: "failover".to_string(), reason: "Invariant violated in FailoverProvider: providers were exhausted but no last_error was recorded (this branch should be unreachable; possible causes: no provider attempts were made or `available` was unexpectedly empty).".to_string(), })) } } #[async_trait] impl LlmProvider for FailoverProvider { fn model_name(&self) -> &str { self.providers[self.last_used.load(Ordering::Relaxed)].model_name() } fn cost_per_token(&self) -> (Decimal, Decimal) { self.providers[self.last_used.load(Ordering::Relaxed)].cost_per_token() } fn cache_write_multiplier(&self) -> Decimal { self.providers[self.last_used.load(Ordering::Relaxed)].cache_write_multiplier() } fn cache_read_discount(&self) -> Decimal { self.providers[self.last_used.load(Ordering::Relaxed)].cache_read_discount() } async fn complete(&self, request: CompletionRequest) -> Result { let (provider_idx, response) = self .try_providers(|provider| { let req = request.clone(); async move { provider.complete(req).await } }) .await?; self.bind_provider_to_current_task(provider_idx); Ok(response) } async fn complete_with_tools( &self, request: ToolCompletionRequest, ) -> Result { let (provider_idx, response) = self .try_providers(|provider| { let req = request.clone(); async move { provider.complete_with_tools(req).await } }) .await?; self.bind_provider_to_current_task(provider_idx); Ok(response) } fn active_model_name(&self) -> String { self.providers[self.last_used.load(Ordering::Relaxed)].active_model_name() } fn set_model(&self, model: &str) -> Result<(), LlmError> { for provider in &self.providers { provider.set_model(model)?; } Ok(()) } async fn list_models(&self) -> Result, LlmError> { let mut all_models = Vec::new(); for provider in &self.providers { match provider.list_models().await { Ok(models) => all_models.extend(models), Err(err) => { tracing::warn!( provider = %provider.model_name(), error = %err, "Failed to list models from provider, skipping" ); } } } all_models.sort(); all_models.dedup(); Ok(all_models) } async fn model_metadata(&self) -> Result { self.providers[self.last_used.load(Ordering::Relaxed)] .model_metadata() .await } fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { self.providers[self.last_used.load(Ordering::Relaxed)] .calculate_cost(input_tokens, output_tokens) } fn effective_model_name(&self, requested_model: Option<&str>) -> String { if let Some(provider_idx) = self.take_bound_provider_for_current_task() { return self.providers[provider_idx].effective_model_name(requested_model); } self.providers[self.last_used.load(Ordering::Relaxed)].effective_model_name(requested_model) } } #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, RwLock}; use std::time::Duration; use crate::llm::provider::{CompletionResponse, FinishReason, ToolCompletionResponse}; /// A mock LLM provider that returns a predetermined result. struct MockProvider { name: String, active_model: RwLock, input_cost: Decimal, output_cost: Decimal, complete_result: Mutex>>, tool_complete_result: Mutex>>, } impl MockProvider { fn succeeding(name: &str, content: &str) -> Self { Self { name: name.to_string(), active_model: RwLock::new(name.to_string()), input_cost: Decimal::ZERO, output_cost: Decimal::ZERO, complete_result: Mutex::new(Some(Ok(CompletionResponse { content: content.to_string(), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }))), tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse { content: Some(content.to_string()), tool_calls: vec![], input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }))), } } fn succeeding_with_cost( name: &str, content: &str, input_cost: Decimal, output_cost: Decimal, ) -> Self { Self { input_cost, output_cost, ..Self::succeeding(name, content) } } fn failing_retryable(name: &str) -> Self { Self { name: name.to_string(), active_model: RwLock::new(name.to_string()), input_cost: Decimal::ZERO, output_cost: Decimal::ZERO, complete_result: Mutex::new(Some(Err(LlmError::RequestFailed { provider: name.to_string(), reason: "server error".to_string(), }))), tool_complete_result: Mutex::new(Some(Err(LlmError::RequestFailed { provider: name.to_string(), reason: "server error".to_string(), }))), } } fn failing_non_retryable(name: &str) -> Self { Self { name: name.to_string(), active_model: RwLock::new(name.to_string()), input_cost: Decimal::ZERO, output_cost: Decimal::ZERO, complete_result: Mutex::new(Some(Err(LlmError::AuthFailed { provider: name.to_string(), }))), tool_complete_result: Mutex::new(Some(Err(LlmError::AuthFailed { provider: name.to_string(), }))), } } fn failing_rate_limited(name: &str) -> Self { Self { name: name.to_string(), active_model: RwLock::new(name.to_string()), input_cost: Decimal::ZERO, output_cost: Decimal::ZERO, complete_result: Mutex::new(Some(Err(LlmError::RateLimited { provider: name.to_string(), retry_after: Some(Duration::from_secs(30)), }))), tool_complete_result: Mutex::new(Some(Err(LlmError::RateLimited { provider: name.to_string(), retry_after: Some(Duration::from_secs(30)), }))), } } } #[async_trait] impl LlmProvider for MockProvider { fn model_name(&self) -> &str { &self.name } fn cost_per_token(&self) -> (Decimal, Decimal) { (self.input_cost, self.output_cost) } async fn complete( &self, _request: CompletionRequest, ) -> Result { self.complete_result .lock() .unwrap() .take() .expect("MockProvider::complete called more than once") } async fn complete_with_tools( &self, _request: ToolCompletionRequest, ) -> Result { self.tool_complete_result .lock() .unwrap() .take() .expect("MockProvider::complete_with_tools called more than once") } async fn list_models(&self) -> Result, LlmError> { Ok(vec![self.name.clone()]) } fn active_model_name(&self) -> String { self.active_model.read().unwrap().clone() } fn set_model(&self, model: &str) -> Result<(), LlmError> { *self.active_model.write().unwrap() = model.to_string(); Ok(()) } } fn make_request() -> CompletionRequest { CompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")]) } fn make_tool_request() -> ToolCompletionRequest { ToolCompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")], vec![]) } // Test 1: Primary succeeds, no failover occurs. #[tokio::test] async fn primary_succeeds_no_failover() { let primary = Arc::new(MockProvider::succeeding("primary", "primary response")); let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response")); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); let response = failover.complete(make_request()).await.unwrap(); assert_eq!(response.content, "primary response"); } // Test 2: Primary fails with retryable error, fallback succeeds. #[tokio::test] async fn primary_fails_retryable_fallback_succeeds() { let primary = Arc::new(MockProvider::failing_retryable("primary")); let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response")); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); let response = failover.complete(make_request()).await.unwrap(); assert_eq!(response.content, "fallback response"); } // Test 3: All providers fail, returns last error. #[tokio::test] async fn all_providers_fail_returns_last_error() { let primary = Arc::new(MockProvider::failing_retryable("primary")); let fallback = Arc::new(MockProvider::failing_retryable("fallback")); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); let err = failover.complete(make_request()).await.unwrap_err(); match err { LlmError::RequestFailed { provider, .. } => { assert_eq!(provider, "fallback"); } other => panic!("expected RequestFailed, got: {other:?}"), } } // Test 4: Non-retryable error fails immediately, no failover. #[tokio::test] async fn non_retryable_error_fails_immediately() { let primary = Arc::new(MockProvider::failing_non_retryable("primary")); let fallback = Arc::new(MockProvider::succeeding("fallback", "fallback response")); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); let err = failover.complete(make_request()).await.unwrap_err(); match err { LlmError::AuthFailed { provider } => { assert_eq!(provider, "primary"); } other => panic!("expected AuthFailed, got: {other:?}"), } } // Test 5: Three providers, first two fail (retryable), third succeeds. #[tokio::test] async fn three_providers_first_two_fail_third_succeeds() { let p1 = Arc::new(MockProvider::failing_retryable("provider-1")); let p2 = Arc::new(MockProvider::failing_rate_limited("provider-2")); let p3 = Arc::new(MockProvider::succeeding("provider-3", "third time lucky")); let failover = FailoverProvider::new(vec![p1, p2, p3]).unwrap(); let response = failover.complete(make_request()).await.unwrap(); assert_eq!(response.content, "third time lucky"); } // Test: complete_with_tools follows same failover logic. #[tokio::test] async fn complete_with_tools_failover() { let primary = Arc::new(MockProvider::failing_retryable("primary")); let fallback = Arc::new(MockProvider::succeeding("fallback", "tools fallback")); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); let response = failover .complete_with_tools(make_tool_request()) .await .unwrap(); assert_eq!(response.content.as_deref(), Some("tools fallback")); } // Test: model_name and cost_per_token reflect the last-used provider. #[tokio::test] async fn model_name_and_cost_track_last_used_provider() { let fallback_cost = Decimal::new(15, 6); // 0.000015 let primary = Arc::new(MockProvider::failing_retryable("primary-model")); let fallback = Arc::new(MockProvider::succeeding_with_cost( "fallback-model", "ok", fallback_cost, fallback_cost, )); let failover = FailoverProvider::new(vec![primary, fallback]).unwrap(); // Before any call, defaults to primary (index 0). assert_eq!(failover.model_name(), "primary-model"); assert_eq!(failover.cost_per_token(), (Decimal::ZERO, Decimal::ZERO)); // After failover, should reflect the fallback provider. let _ = failover.complete(make_request()).await.unwrap(); assert_eq!(failover.model_name(), "fallback-model"); assert_eq!(failover.cost_per_token(), (fallback_cost, fallback_cost)); } // Test: model reporting is request-scoped under concurrent requests. #[tokio::test] async fn effective_model_name_is_request_scoped_under_concurrency() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(60), failure_threshold: 3, }; let primary = Arc::new(MultiCallMockProvider::fail_then_ok("primary", 1)); let fallback = Arc::new(MultiCallMockProvider::always_ok("fallback")); let failover = Arc::new(FailoverProvider::with_cooldown(vec![primary, fallback], config).unwrap()); let (first_done_tx, first_done_rx) = tokio::sync::oneshot::channel::<()>(); let (second_done_tx, second_done_rx) = tokio::sync::oneshot::channel::<()>(); let failover_a = Arc::clone(&failover); let task_a = tokio::spawn(async move { // First request: primary fails once, fallback serves. let _ = failover_a.complete(make_request()).await.unwrap(); let _ = first_done_tx.send(()); // Wait until the second request finishes and updates global state. let _ = second_done_rx.await; failover_a.effective_model_name(None) }); let failover_b = Arc::clone(&failover); let task_b = tokio::spawn(async move { let _ = first_done_rx.await; // Second request: primary now succeeds. let _ = failover_b.complete(make_request()).await.unwrap(); let model = failover_b.effective_model_name(None); let _ = second_done_tx.send(()); model }); let model_b = task_b.await.unwrap(); let model_a = task_a.await.unwrap(); assert_eq!(model_a, "fallback"); assert_eq!(model_b, "primary"); } // Test: list_models aggregates from all providers. #[tokio::test] async fn list_models_aggregates_all() { let p1 = Arc::new(MockProvider::succeeding("model-a", "ok")); let p2 = Arc::new(MockProvider::succeeding("model-b", "ok")); let failover = FailoverProvider::new(vec![p1, p2]).unwrap(); let models = failover.list_models().await.unwrap(); assert!(models.contains(&"model-a".to_string())); assert!(models.contains(&"model-b".to_string())); } // --- MultiCallMockProvider for cooldown tests --- // // Unlike `MockProvider` which uses `.take()` (single-use), this mock // tracks a call counter and returns errors for the first N calls, // then succeeds. struct MultiCallMockProvider { name: String, /// How many calls should fail before succeeding. 0 = always succeed. fail_count: u32, /// Atomically tracks how many times `complete` has been called. calls: AtomicU32, /// If true, failures are non-retryable (AuthFailed). non_retryable: bool, } impl MultiCallMockProvider { /// Always succeeds. fn always_ok(name: &str) -> Self { Self { name: name.to_string(), fail_count: 0, calls: AtomicU32::new(0), non_retryable: false, } } /// Fails with retryable error for the first `n` calls, then succeeds. fn fail_then_ok(name: &str, n: u32) -> Self { Self { name: name.to_string(), fail_count: n, calls: AtomicU32::new(0), non_retryable: false, } } /// Always fails with retryable error. fn always_fail(name: &str) -> Self { Self { name: name.to_string(), fail_count: u32::MAX, calls: AtomicU32::new(0), non_retryable: false, } } /// Always fails with non-retryable error. fn always_fail_non_retryable(name: &str) -> Self { Self { name: name.to_string(), fail_count: u32::MAX, calls: AtomicU32::new(0), non_retryable: true, } } fn call_count(&self) -> u32 { self.calls.load(Ordering::Relaxed) } } #[async_trait] impl LlmProvider for MultiCallMockProvider { fn model_name(&self) -> &str { &self.name } fn cost_per_token(&self) -> (Decimal, Decimal) { (Decimal::ZERO, Decimal::ZERO) } async fn complete( &self, _request: CompletionRequest, ) -> Result { let n = self.calls.fetch_add(1, Ordering::Relaxed); if n < self.fail_count { if self.non_retryable { return Err(LlmError::AuthFailed { provider: self.name.clone(), }); } return Err(LlmError::RequestFailed { provider: self.name.clone(), reason: format!("call {} failed", n), }); } Ok(CompletionResponse { content: format!("{} ok", self.name), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } async fn complete_with_tools( &self, _request: ToolCompletionRequest, ) -> Result { let n = self.calls.fetch_add(1, Ordering::Relaxed); if n < self.fail_count { if self.non_retryable { return Err(LlmError::AuthFailed { provider: self.name.clone(), }); } return Err(LlmError::RequestFailed { provider: self.name.clone(), reason: format!("call {} failed", n), }); } Ok(ToolCompletionResponse { content: Some(format!("{} ok", self.name)), tool_calls: vec![], input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } async fn list_models(&self) -> Result, LlmError> { Ok(vec![self.name.clone()]) } } // --- Cooldown tests --- // Cooldown test 1: Provider enters cooldown after `threshold` consecutive failures. #[tokio::test] async fn cooldown_activates_after_threshold() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 2, }; let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // Request 1: p1 fails (count=1, below threshold), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 1); // Request 2: p1 fails again (count=2, reaches threshold → cooldown), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 2); // Request 3: p1 should be skipped (in cooldown), only p2 called. let prev_p1_calls = p1.call_count(); let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); // p1 was NOT called again. assert_eq!(p1.call_count(), prev_p1_calls); } // Cooldown test 2: Cooldown expires after duration, provider is retried. #[tokio::test] async fn cooldown_expires_after_duration() { let config = CooldownConfig { cooldown_duration: Duration::from_millis(1), failure_threshold: 1, }; // p1 fails once then succeeds (fail_then_ok with n=1 would work, // but we use always_fail to prove it's skipped, then swap). let p1 = Arc::new(MultiCallMockProvider::fail_then_ok("p1", 2)); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // Request 1: p1 fails (threshold=1, enters cooldown immediately), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 1); // Request 2: p1 in cooldown, skipped. Only p2 called. // (But cooldown is 1ms, so wait a bit to let it expire.) tokio::time::sleep(Duration::from_millis(5)).await; // After sleep, cooldown should have expired. p1 gets tried again. // p1 is set to fail 2 times total, so call #2 (index 1) still fails. // But it proves p1 was attempted again after cooldown expired. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(p1.call_count(), 2); // p1 was retried assert_eq!(r.content, "p2 ok"); // p2 handled it // Wait again for cooldown to expire, p1 call #3 (index 2) succeeds. tokio::time::sleep(Duration::from_millis(5)).await; let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p1 ok"); assert_eq!(p1.call_count(), 3); } // Cooldown test 3: Never skip all providers — oldest-cooled one is tried. #[tokio::test] async fn never_skip_all_providers() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 1, }; // Both providers always fail. let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_fail("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // Request 1: both tried, both fail, both enter cooldown. let _ = failover.complete(make_request()).await; assert_eq!(p1.call_count(), 1); assert_eq!(p2.call_count(), 1); // Request 2: all in cooldown, but the oldest-cooled one (p1, activated // first) should be tried. let prev_total = p1.call_count() + p2.call_count(); let _ = failover.complete(make_request()).await; let new_total = p1.call_count() + p2.call_count(); // Exactly one more call was made (to the oldest-cooled provider). assert_eq!(new_total, prev_total + 1); } // Cooldown test 4: Success resets failure count so it never reaches threshold. // // With threshold=3, accumulate 2 failures then succeed. Verify the // atomic counter is back to 0 and no cooldown was activated. Then // use a second provider pair to show that without the reset, 3 // consecutive failures DO trigger cooldown (control case). #[tokio::test] async fn reset_on_success() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 3, }; // p1 fails for calls 0,1 then succeeds on call 2+. let p1 = Arc::new(MultiCallMockProvider::fail_then_ok("p1", 2)); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config.clone()).unwrap(); // Request 1: p1 fails (failure_count=1), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); // Request 2: p1 fails (failure_count=2, still below threshold=3), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 2); // Request 3: p1 succeeds (call index 2) → counter resets to 0. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p1 ok"); assert_eq!(p1.call_count(), 3); // Verify counter was reset to 0 and no cooldown activated. let nanos = failover.now_nanos(); let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64; assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos)); assert_eq!( failover.cooldowns[0].failure_count.load(Ordering::Relaxed), 0 ); // Control: without a success in the middle, 3 failures DO trigger cooldown. let p3 = Arc::new(MultiCallMockProvider::always_fail("p3")); let p4 = Arc::new(MultiCallMockProvider::always_ok("p4")); let control = FailoverProvider::with_cooldown(vec![p3.clone(), p4.clone()], config).unwrap(); for _ in 0..3 { let _ = control.complete(make_request()).await.unwrap(); } let nanos = control.now_nanos(); assert!(control.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos)); } // Cooldown test 5: threshold-1 failures don't trigger cooldown, threshold does. #[tokio::test] async fn threshold_boundary() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 3, }; let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // 2 requests: p1 fails twice (below threshold of 3), not in cooldown. for _ in 0..2 { let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); } assert_eq!(p1.call_count(), 2); // p1 should still be available (not in cooldown). let nanos = failover.now_nanos(); let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64; assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos)); // 3rd request: p1 fails → reaches threshold → enters cooldown. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 3); let nanos = failover.now_nanos(); assert!(failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos)); // 4th request: p1 should be skipped. let prev = p1.call_count(); let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), prev); // not called } // Cooldown test 6: Non-retryable error returns immediately, no failure bump. #[tokio::test] async fn non_retryable_does_not_increment_cooldown() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 1, }; let p1 = Arc::new(MultiCallMockProvider::always_fail_non_retryable("p1")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // Non-retryable error should return immediately. let err = failover.complete(make_request()).await.unwrap_err(); assert!(matches!(err, LlmError::AuthFailed { .. })); assert_eq!(p1.call_count(), 1); // p2 should NOT have been called (non-retryable = no failover). assert_eq!(p2.call_count(), 0); // p1 should NOT be in cooldown (non-retryable doesn't bump count). let nanos = failover.now_nanos(); let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64; assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos)); } // Cooldown test 7: Three providers, first in cooldown, second/third available. #[tokio::test] async fn three_providers_mixed_cooldown() { let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 1, }; let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let p3 = Arc::new(MultiCallMockProvider::always_ok("p3")); let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone(), p3.clone()], config) .unwrap(); // Request 1: p1 fails → enters cooldown (threshold=1), p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), 1); // Request 2: p1 skipped (cooldown), p2 and p3 available. let prev = p1.call_count(); let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2 ok"); assert_eq!(p1.call_count(), prev); // p1 skipped } // Test: is_retryable correctly classifies errors. #[test] fn retryable_classification() { // Retryable assert!(is_retryable(&LlmError::RequestFailed { provider: "p".into(), reason: "err".into(), })); assert!(is_retryable(&LlmError::RateLimited { provider: "p".into(), retry_after: None, })); assert!(is_retryable(&LlmError::InvalidResponse { provider: "p".into(), reason: "bad json".into(), })); assert!(is_retryable(&LlmError::SessionRenewalFailed { provider: "p".into(), reason: "timeout".into(), })); assert!(is_retryable(&LlmError::Io(std::io::Error::new( std::io::ErrorKind::ConnectionReset, "reset" )))); // Non-retryable assert!(!is_retryable(&LlmError::AuthFailed { provider: "p".into(), })); assert!(!is_retryable(&LlmError::SessionExpired { provider: "p".into(), })); assert!(!is_retryable(&LlmError::ContextLengthExceeded { used: 100_000, limit: 50_000, })); assert!(!is_retryable(&LlmError::ModelNotAvailable { provider: "p".into(), model: "m".into(), })); } // Test: empty providers list returns error (not panic). #[test] fn empty_providers_returns_error() { let result = FailoverProvider::new(vec![]); assert!(result.is_err()); } // Test: activate_cooldown(0) still activates cooldown (sentinel collision fix). #[test] fn cooldown_at_nanos_zero_still_activates() { let cd = ProviderCooldown::new(); cd.activate_cooldown(0); assert!(cd.is_in_cooldown(0, 1000)); assert_eq!(cd.cooldown_activated_nanos.load(Ordering::Relaxed), 1); } // Test: set_model propagates to all providers and active_model_name reflects change. #[test] fn set_model_propagates_to_all_providers() { let p1: Arc = Arc::new(MockProvider::succeeding("model-a", "ok")); let p2: Arc = Arc::new(MockProvider::succeeding("model-b", "ok")); let failover = FailoverProvider::new(vec![ Arc::clone(&p1) as Arc, Arc::clone(&p2) as Arc, ]) .unwrap(); // Before: active_model_name delegates to last_used (index 0 = p1). assert_eq!(failover.active_model_name(), "model-a"); // Switch model. failover.set_model("new-model").unwrap(); // Both inner providers should reflect the change. assert_eq!(p1.active_model_name(), "new-model"); assert_eq!(p2.active_model_name(), "new-model"); // FailoverProvider itself should report the new model. assert_eq!(failover.active_model_name(), "new-model"); } // === QA Plan P2 - 4.1: Provider chaos tests === #[tokio::test] async fn hanging_provider_failover_to_healthy_one() { // When primary hangs, caller can timeout and the secondary should be reachable // on a fresh request. The failover itself doesn't timeout individual providers // (that's the HTTP client's job), but after the first provider enters cooldown // from repeated failures, the failover skips it. let p1 = Arc::new(MultiCallMockProvider::always_fail("p1-broken")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2-healthy")); let config = CooldownConfig { cooldown_duration: Duration::from_secs(60), failure_threshold: 1, }; let failover = FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap(); // First request: p1 fails → cooldown, p2 succeeds. let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2-healthy ok"); // Second request: p1 skipped (in cooldown), p2 serves directly. let prev_p1 = p1.call_count(); let r = failover.complete(make_request()).await.unwrap(); assert_eq!(r.content, "p2-healthy ok"); assert_eq!(p1.call_count(), prev_p1, "p1 should be skipped in cooldown"); } #[tokio::test] async fn all_providers_fail_returns_error_not_panic() { let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_fail("p2")); let p3 = Arc::new(MultiCallMockProvider::always_fail("p3")); let failover = FailoverProvider::new(vec![p1 as Arc, p2, p3]).unwrap(); // Should return an error, not panic. let result = failover.complete(make_request()).await; assert!(result.is_err()); } #[tokio::test] async fn failover_with_tools_follows_same_path() { let p1 = Arc::new(MultiCallMockProvider::always_fail("p1")); let p2 = Arc::new(MultiCallMockProvider::always_ok("p2")); let failover = FailoverProvider::new(vec![p1 as Arc, p2]).unwrap(); let result = failover.complete_with_tools(make_tool_request()).await; assert!(result.is_ok()); assert_eq!(result.unwrap().content.unwrap(), "p2 ok"); } #[tokio::test] async fn single_provider_failover_still_works() { let p1 = Arc::new(MultiCallMockProvider::always_ok("solo")); let failover = FailoverProvider::new(vec![p1 as Arc]).unwrap(); let result = failover.complete(make_request()).await; assert!(result.is_ok()); assert_eq!(result.unwrap().content, "solo ok"); } // === QA Plan 2.6: Failover edge case tests === /// When all providers fail with retryable errors, the failover must /// return a graceful error (not panic via .unwrap()/.expect()). Verify /// the error content includes the last provider's identity. #[tokio::test] async fn test_failover_all_providers_fail_no_panic() { let p1 = Arc::new(MultiCallMockProvider::always_fail("alpha")); let p2 = Arc::new(MultiCallMockProvider::always_fail("beta")); let p3 = Arc::new(MultiCallMockProvider::always_fail("gamma")); let failover = FailoverProvider::new(vec![ p1 as Arc, p2 as Arc, p3 as Arc, ]) .unwrap(); // All three providers fail. Must return Err, not panic. let result = failover.complete(make_request()).await; assert!(result.is_err(), "should return error, not panic"); let err = result.unwrap_err(); match &err { LlmError::RequestFailed { provider, reason } => { // The last error should come from the last provider tried. assert_eq!( provider, "gamma", "error should identify the last provider tried" ); assert!( reason.contains("failed"), "error reason should describe the failure: {}", reason ); } other => panic!("expected RequestFailed, got: {:?}", other), } // Also test complete_with_tools follows the same graceful path. let p4 = Arc::new(MultiCallMockProvider::always_fail("delta")); let p5 = Arc::new(MultiCallMockProvider::always_fail("epsilon")); let failover2 = FailoverProvider::new(vec![p4 as Arc, p5 as Arc]) .unwrap(); let result = failover2.complete_with_tools(make_tool_request()).await; assert!( result.is_err(), "complete_with_tools should also return error, not panic" ); } /// A single provider that always fails with no fallback available. /// Verifies the failover returns the error from that provider and /// does not panic or produce an "unreachable" invariant violation. #[tokio::test] async fn test_failover_with_single_provider_failing() { let solo = Arc::new(MultiCallMockProvider::always_fail("solo-broken")); let failover = FailoverProvider::new(vec![solo.clone() as Arc]).unwrap(); // First call: should return error from the solo provider. let result = failover.complete(make_request()).await; assert!(result.is_err()); match result.unwrap_err() { LlmError::RequestFailed { provider, .. } => { assert_eq!(provider, "solo-broken"); } other => panic!("expected RequestFailed, got: {:?}", other), } // After repeated failures, the single provider enters cooldown. // But since it's the only provider, the "never skip all" logic // should still try it (as the oldest-cooled provider). let config = CooldownConfig { cooldown_duration: Duration::from_secs(300), failure_threshold: 1, }; let solo2 = Arc::new(MultiCallMockProvider::always_fail("solo-cd")); let failover2 = FailoverProvider::with_cooldown(vec![solo2.clone() as Arc], config) .unwrap(); // First call: fails, enters cooldown (threshold=1). let _ = failover2.complete(make_request()).await; assert_eq!(solo2.call_count(), 1); // Second call: provider is in cooldown, but it's the only one, // so "never skip all" should try it anyway. let result = failover2.complete(make_request()).await; assert!(result.is_err(), "should still fail but not panic"); assert_eq!( solo2.call_count(), 2, "sole provider should be retried despite cooldown" ); // Third call: same behavior, no state corruption. let result = failover2.complete(make_request()).await; assert!(result.is_err()); assert_eq!(solo2.call_count(), 3); } }