use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use tokio::time;
use tracing::warn;
use crate::types::{
ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities, LlmProvider, RunnerError,
};
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 0,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(5),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FallThrough {
#[default]
Always,
ProviderFault,
}
impl FallThrough {
#[must_use]
pub const fn allows(self, kind: ErrorKind) -> bool {
match self {
Self::Always => true,
Self::ProviderFault => kind.is_provider_fault(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ResponsePolicy {
pub fall_through: FallThrough,
pub reject_empty_completion: bool,
pub own_model_per_tier: bool,
}
impl ResponsePolicy {
#[must_use]
pub const fn permissive() -> Self {
Self {
fall_through: FallThrough::Always,
reject_empty_completion: false,
own_model_per_tier: false,
}
}
#[must_use]
pub const fn strict() -> Self {
Self {
fall_through: FallThrough::ProviderFault,
reject_empty_completion: true,
own_model_per_tier: true,
}
}
}
#[must_use]
pub fn is_empty_completion(response: &ChatResponse) -> bool {
let has_tool_calls = response
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty());
response.content.trim().is_empty() && !has_tool_calls
}
#[derive(Clone, Copy)]
pub struct Tier<'a> {
pub position: usize,
pub provider: &'a dyn LlmProvider,
}
pub enum FallthroughReason<'a> {
Skipped(&'static str),
EmptyCompletion,
Error(&'a RunnerError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attempt {
Try,
Skip(&'static str),
}
pub trait FallbackObserver: Send + Sync {
fn before_attempt(&self, tier: Tier<'_>) -> Attempt {
let _ = tier;
Attempt::Try
}
fn on_fallthrough(&self, from: Tier<'_>, to: Tier<'_>, reason: FallthroughReason<'_>) {
let _ = (from, to, reason);
}
fn on_success(&self, tier: Tier<'_>) {
let _ = tier;
}
fn on_exhausted(&self, last: Tier<'_>, error: &RunnerError) {
let _ = (last, error);
}
}
type TierCall<'r, T> = Pin<Box<dyn Future<Output = Result<T, RunnerError>> + Send + 'r>>;
type TierMethod<'m, T> =
&'m (dyn for<'r> Fn(&'r dyn LlmProvider, &'r ChatRequest) -> TierCall<'r, T> + Sync);
pub struct FallbackProvider {
providers: Vec<Box<dyn LlmProvider>>,
display_name: String,
combined_models: Vec<String>,
retry_config: RetryConfig,
policy: ResponsePolicy,
observer: Option<Arc<dyn FallbackObserver>>,
quota_cooldown: Duration,
cooldown_until: Mutex<Vec<Option<Instant>>>,
}
const DEFAULT_QUOTA_COOLDOWN: Duration = Duration::from_mins(15);
impl FallbackProvider {
pub fn new(providers: Vec<Box<dyn LlmProvider>>) -> Result<Self, RunnerError> {
Self::with_retry(providers, RetryConfig::default())
}
pub fn with_retry(
providers: Vec<Box<dyn LlmProvider>>,
retry_config: RetryConfig,
) -> Result<Self, RunnerError> {
if providers.is_empty() {
return Err(RunnerError::config(
"FallbackProvider requires at least one provider",
));
}
let names: Vec<&str> = providers.iter().map(|p| p.name()).collect();
let display_name = format!("Fallback ({})", names.join(", "));
let mut combined_models = Vec::new();
for provider in &providers {
for model in provider.available_models() {
if !combined_models.contains(model) {
combined_models.push(model.clone());
}
}
}
let tiers = providers.len();
Ok(Self {
providers,
display_name,
combined_models,
retry_config,
policy: ResponsePolicy::permissive(),
observer: None,
quota_cooldown: DEFAULT_QUOTA_COOLDOWN,
cooldown_until: Mutex::new(vec![None; tiers]),
})
}
#[must_use]
pub const fn with_quota_cooldown(mut self, cooldown: Duration) -> Self {
self.quota_cooldown = cooldown;
self
}
fn in_cooldown(&self, position: usize) -> bool {
let slots = self
.cooldown_until
.lock()
.unwrap_or_else(PoisonError::into_inner);
slots
.get(position)
.copied()
.flatten()
.is_some_and(|until| Instant::now() < until)
}
fn start_cooldown(&self, position: usize) {
let mut slots = self
.cooldown_until
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(slot) = slots.get_mut(position) {
*slot = Some(Instant::now() + self.quota_cooldown);
}
}
fn end_cooldown(&self, position: usize) {
let mut slots = self
.cooldown_until
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(slot) = slots.get_mut(position) {
*slot = None;
}
}
#[must_use]
pub const fn with_fallthrough(mut self, policy: ResponsePolicy) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn with_observer(mut self, observer: Arc<dyn FallbackObserver>) -> Self {
self.observer = Some(observer);
self
}
fn backoff_delay(&self, attempt: u32) -> Duration {
let delay = self
.retry_config
.base_delay
.saturating_mul(2u32.saturating_pow(attempt));
delay.min(self.retry_config.max_delay)
}
fn tier(&self, position: usize) -> Tier<'_> {
Tier {
position,
provider: self.providers[position].as_ref(),
}
}
fn before_attempt(&self, tier: Tier<'_>) -> Attempt {
self.observer
.as_ref()
.map_or(Attempt::Try, |o| o.before_attempt(tier))
}
fn on_fallthrough(&self, from: Tier<'_>, to: Tier<'_>, reason: FallthroughReason<'_>) {
if let Some(observer) = &self.observer {
observer.on_fallthrough(from, to, reason);
}
}
fn on_success(&self, tier: Tier<'_>) {
if let Some(observer) = &self.observer {
observer.on_success(tier);
}
}
fn on_exhausted(&self, last: Tier<'_>, error: &RunnerError) {
if let Some(observer) = &self.observer {
observer.on_exhausted(last, error);
}
}
fn forwarded<'r>(&self, original: &'r ChatRequest, held: &'r mut Option<ChatRequest>) {
if self.policy.own_model_per_tier && held.is_none() {
let mut cleared = original.clone();
cleared.model = None;
*held = Some(cleared);
}
}
async fn attempt<T>(
&self,
tier: Tier<'_>,
request: &ChatRequest,
call: TierMethod<'_, T>,
) -> Result<T, RunnerError> {
let mut attempt = 0;
loop {
match call(tier.provider, request).await {
Ok(value) => return Ok(value),
Err(err) => {
let retryable =
err.kind.is_transient() && attempt < self.retry_config.max_retries;
if !retryable {
return Err(err);
}
let delay = self.backoff_delay(attempt);
#[allow(clippy::cast_possible_truncation)]
let delay_ms = delay.as_millis() as u64;
warn!(
provider = tier.provider.name(),
attempt,
error = %err,
delay_ms,
"fallback: transient error, retrying after backoff"
);
time::sleep(delay).await;
attempt += 1;
}
}
}
}
async fn drive<T>(
&self,
request: &ChatRequest,
reject: impl Fn(&T) -> bool + Send + Sync,
call: TierMethod<'_, T>,
) -> Result<T, RunnerError> {
let last = self.providers.len().saturating_sub(1);
let mut held: Option<ChatRequest> = None;
for position in 0..self.providers.len() {
let tier = self.tier(position);
let has_successor = position < last;
if has_successor {
let verdict = if self.in_cooldown(position) {
Attempt::Skip("quota cooldown")
} else {
self.before_attempt(tier)
};
if let Attempt::Skip(reason) = verdict {
self.on_fallthrough(
tier,
self.tier(position + 1),
FallthroughReason::Skipped(reason),
);
self.forwarded(request, &mut held);
continue;
}
}
let current: &ChatRequest = held.as_ref().unwrap_or(request);
let outcome = self.attempt(tier, current, call).await;
match outcome {
Ok(value) if has_successor && reject(&value) => {
warn!(
provider = tier.provider.name(),
"fallback: provider answered with nothing deliverable, trying next"
);
self.on_fallthrough(
tier,
self.tier(position + 1),
FallthroughReason::EmptyCompletion,
);
self.forwarded(request, &mut held);
}
Ok(value) => {
self.end_cooldown(position);
self.on_success(tier);
return Ok(value);
}
Err(err) if !self.policy.fall_through.allows(err.kind) => {
return Err(err);
}
Err(err) if has_successor => {
if err.kind == ErrorKind::RateLimit {
self.start_cooldown(position);
}
warn!(
provider = tier.provider.name(),
error = %err,
"fallback: provider failed, trying next"
);
self.on_fallthrough(
tier,
self.tier(position + 1),
FallthroughReason::Error(&err),
);
self.forwarded(request, &mut held);
}
Err(err) => {
if err.kind == ErrorKind::RateLimit {
self.start_cooldown(position);
}
warn!(
provider = tier.provider.name(),
error = %err,
"fallback: last provider failed, chain exhausted"
);
self.on_exhausted(tier, &err);
return Err(err);
}
}
}
Err(RunnerError::internal("no providers configured"))
}
}
#[async_trait]
impl LlmProvider for FallbackProvider {
fn name(&self) -> &'static str {
"fallback"
}
fn display_name(&self) -> &str {
&self.display_name
}
fn capabilities(&self) -> LlmCapabilities {
self.providers
.iter()
.fold(LlmCapabilities::empty(), |acc, p| acc | p.capabilities())
}
fn default_model(&self) -> &str {
self.providers[0].default_model()
}
fn available_models(&self) -> &[String] {
&self.combined_models
}
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let reject_empty = self.policy.reject_empty_completion;
self.drive(
request,
move |response: &ChatResponse| reject_empty && is_empty_completion(response),
&|provider, request| Box::pin(provider.complete(request)),
)
.await
}
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
self.drive(request, |_: &ChatStream| false, &|provider, request| {
Box::pin(provider.complete_stream(request))
})
.await
}
async fn health_check(&self) -> Result<bool, RunnerError> {
let mut last = Ok(false);
for provider in &self.providers {
let outcome = provider.health_check().await;
if matches!(outcome, Ok(true)) {
return Ok(true);
}
last = outcome;
}
last
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities,
LlmProvider, RunnerError,
};
use async_trait::async_trait;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
struct TestProvider {
provider_name: &'static str,
display: &'static str,
caps: LlmCapabilities,
models: Vec<String>,
responses: Mutex<Vec<Result<ChatResponse, RunnerError>>>,
call_count: AtomicU32,
health: Result<bool, RunnerError>,
}
impl TestProvider {
fn ok(name: &'static str, content: &str) -> Self {
Self {
provider_name: name,
display: name,
caps: LlmCapabilities::text_only(),
models: vec![format!("{name}-model")],
responses: Mutex::new(vec![Ok(ChatResponse {
content: content.to_owned(),
model: format!("{name}-model"),
usage: None,
finish_reason: Some("stop".to_owned()),
warnings: None,
tool_calls: None,
})]),
call_count: AtomicU32::new(0),
health: Ok(true),
}
}
fn failing(name: &'static str) -> Self {
Self::failing_with_kind(name, ErrorKind::ExternalService)
}
fn failing_with_kind(name: &'static str, kind: ErrorKind) -> Self {
let err = RunnerError {
kind,
message: format!("{name}: down"),
};
Self {
provider_name: name,
display: name,
caps: LlmCapabilities::FUNCTION_CALLING,
models: vec![format!("{name}-model")],
responses: Mutex::new(vec![Err(err)]),
call_count: AtomicU32::new(0),
health: Ok(false),
}
}
fn health_erroring(name: &'static str, err: RunnerError) -> Self {
Self {
provider_name: name,
display: name,
caps: LlmCapabilities::text_only(),
models: vec![format!("{name}-model")],
responses: Mutex::new(vec![]),
call_count: AtomicU32::new(0),
health: Err(err),
}
}
fn with_responses(
name: &'static str,
responses: Vec<Result<ChatResponse, RunnerError>>,
) -> Self {
Self {
provider_name: name,
display: name,
caps: LlmCapabilities::text_only(),
models: vec![format!("{name}-model")],
responses: Mutex::new(responses),
call_count: AtomicU32::new(0),
health: Ok(true),
}
}
}
#[tokio::test]
async fn strict_policy_falls_through_on_a_tiers_rate_limit() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing_with_kind(
"quota",
ErrorKind::RateLimit,
)),
Box::new(TestProvider::ok("second", "served by second")),
];
let fallback = FallbackProvider::new(providers)
.expect("non-empty") .with_fallthrough(ResponsePolicy::strict());
let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback
.complete(&request)
.await
.expect("second tier answers"); assert_eq!(response.content, "served by second");
}
#[tokio::test]
async fn strict_policy_propagates_an_invalid_request() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing_with_kind(
"first",
ErrorKind::InvalidRequest,
)),
Box::new(TestProvider::ok("second", "never asked")),
];
let fallback = FallbackProvider::new(providers)
.expect("non-empty") .with_fallthrough(ResponsePolicy::strict());
let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let err = fallback.complete(&request).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidRequest);
assert_eq!(err.message, "first: down");
}
fn make_response(content: &str) -> ChatResponse {
ChatResponse {
content: content.to_owned(),
model: "test-model".to_owned(),
usage: None,
finish_reason: Some("stop".to_owned()),
warnings: None,
tool_calls: None,
}
}
#[async_trait]
impl LlmProvider for TestProvider {
fn name(&self) -> &'static str {
self.provider_name
}
fn display_name(&self) -> &str {
self.display
}
fn capabilities(&self) -> LlmCapabilities {
self.caps
}
fn default_model(&self) -> &str {
&self.models[0]
}
fn available_models(&self) -> &[String] {
&self.models
}
async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
self.call_count.fetch_add(1, Ordering::SeqCst);
let mut responses = self.responses.lock().expect("test lock"); if responses.is_empty() {
Err(RunnerError::internal("no more responses"))
} else {
responses.remove(0)
}
}
async fn complete_stream(&self, _request: &ChatRequest) -> Result<ChatStream, RunnerError> {
self.call_count.fetch_add(1, Ordering::SeqCst);
let mut responses = self.responses.lock().expect("test lock"); if responses.is_empty() {
Err(RunnerError::internal("no more stream responses"))
} else {
match responses.remove(0) {
Ok(_) => Err(RunnerError::internal(
"use complete() for ok responses in test",
)),
Err(e) => Err(e),
}
}
}
async fn health_check(&self) -> Result<bool, RunnerError> {
self.health.clone()
}
}
#[tokio::test]
async fn single_provider_passthrough() {
let providers: Vec<Box<dyn LlmProvider>> =
vec![Box::new(TestProvider::ok("claude", "hello"))];
let fallback = FallbackProvider::new(providers).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback.complete(&request).await.expect("should succeed"); assert_eq!(response.content, "hello");
}
#[tokio::test]
async fn first_fails_second_succeeds() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing("primary")),
Box::new(TestProvider::ok("secondary", "fallback response")),
];
let fallback = FallbackProvider::new(providers).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback
.complete(&request)
.await
.expect("second should work"); assert_eq!(response.content, "fallback response");
}
#[tokio::test]
async fn all_fail_returns_last_error() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing("first")),
Box::new(TestProvider::failing("second")),
];
let fallback = FallbackProvider::new(providers).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let err = fallback.complete(&request).await.unwrap_err();
assert!(err.message.contains("second"));
}
#[tokio::test]
async fn health_or_logic() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing("unhealthy")), Box::new(TestProvider::ok("healthy", "ok")), ];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let healthy = fallback.health_check().await.expect("health check"); assert!(healthy);
}
#[tokio::test]
async fn health_all_down() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing("a")),
Box::new(TestProvider::failing("b")),
];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let healthy = fallback.health_check().await.expect("health check"); assert!(!healthy);
}
#[tokio::test]
async fn health_check_returns_the_last_tiers_error_when_none_is_healthy() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::failing("a")),
Box::new(TestProvider::health_erroring(
"b",
RunnerError::auth_failure("b: invalid api token"),
)),
];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let err = fallback
.health_check()
.await
.expect_err("the last tier's probe error must propagate");
assert_eq!(err.kind, ErrorKind::AuthFailure);
assert!(err.message.contains("invalid api token"), "{err}");
}
#[tokio::test]
async fn health_check_last_tier_unhealthy_outranks_an_earlier_error() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::health_erroring(
"a",
RunnerError::timeout("a: probe timed out"),
)),
Box::new(TestProvider::failing("b")),
];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let healthy = fallback
.health_check()
.await
.expect("Ok(false), not the earlier error"); assert!(!healthy);
}
#[tokio::test]
async fn health_check_any_healthy_tier_wins_over_an_error() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::health_erroring(
"a",
RunnerError::auth_failure("a: down"),
)),
Box::new(TestProvider::ok("b", "ok")),
];
let fallback = FallbackProvider::new(providers).expect("non-empty");
assert!(fallback.health_check().await.expect("healthy")); }
#[test]
fn capabilities_union() {
let providers: Vec<Box<dyn LlmProvider>> = vec![
Box::new(TestProvider::ok("a", "ok")), Box::new(TestProvider::failing("b")), ];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let caps = fallback.capabilities();
assert!(caps.supports_streaming());
assert!(caps.supports_system_messages());
assert!(caps.supports_function_calling());
}
#[test]
fn empty_vec_rejected() {
let providers: Vec<Box<dyn LlmProvider>> = vec![];
let result = FallbackProvider::new(providers);
assert!(result.is_err());
}
#[test]
fn available_models_deduplicated() {
let a = TestProvider {
provider_name: "a",
display: "A",
caps: LlmCapabilities::text_only(),
models: vec!["shared-model".to_owned(), "a-only".to_owned()],
responses: Mutex::new(vec![]),
call_count: AtomicU32::new(0),
health: Ok(true),
};
let b = TestProvider {
provider_name: "b",
display: "B",
caps: LlmCapabilities::text_only(),
models: vec!["shared-model".to_owned(), "b-only".to_owned()],
responses: Mutex::new(vec![]),
call_count: AtomicU32::new(0),
health: Ok(true),
};
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(a), Box::new(b)];
let fallback = FallbackProvider::new(providers).expect("non-empty");
let models = fallback.available_models();
assert_eq!(models.len(), 3);
assert!(models.contains(&"shared-model".to_owned()));
assert!(models.contains(&"a-only".to_owned()));
assert!(models.contains(&"b-only".to_owned()));
}
#[tokio::test]
async fn retry_on_transient_then_succeeds() {
let provider = TestProvider::with_responses(
"alpha",
vec![
Err(RunnerError::timeout("timed out")),
Ok(make_response("recovered")),
],
);
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(provider)];
let retry = RetryConfig {
max_retries: 2,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback.complete(&request).await.expect("should recover"); assert_eq!(response.content, "recovered");
}
#[tokio::test]
async fn no_retry_on_permanent_error() {
let provider = TestProvider::with_responses(
"alpha",
vec![
Err(RunnerError::config("bad config")),
Ok(make_response("should not reach")),
],
);
let backup = TestProvider::ok("beta", "from backup");
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(provider), Box::new(backup)];
let retry = RetryConfig {
max_retries: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback
.complete(&request)
.await
.expect("backup should work"); assert_eq!(response.content, "from backup");
}
#[tokio::test]
async fn retry_exhausts_then_next_provider() {
let primary = TestProvider::with_responses(
"primary",
vec![
Err(RunnerError::timeout("t1")),
Err(RunnerError::timeout("t2")),
Err(RunnerError::timeout("t3")),
],
);
let secondary = TestProvider::ok("secondary", "secondary response");
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(primary), Box::new(secondary)];
let retry = RetryConfig {
max_retries: 2,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let response = fallback
.complete(&request)
.await
.expect("secondary should work"); assert_eq!(response.content, "secondary response");
}
#[tokio::test]
async fn zero_retries_matches_original_behavior() {
let provider = TestProvider::with_responses(
"alpha",
vec![
Err(RunnerError::timeout("t1")),
Ok(make_response("should not reach")),
],
);
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(provider)];
let retry = RetryConfig {
max_retries: 0,
..RetryConfig::default()
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
let err = fallback.complete(&request).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout);
}
#[test]
fn backoff_respects_max_delay() {
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(TestProvider::ok("a", "ok"))];
let retry = RetryConfig {
max_retries: 5,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_millis(500),
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty");
assert_eq!(fallback.backoff_delay(0), Duration::from_millis(100));
assert_eq!(fallback.backoff_delay(1), Duration::from_millis(200));
assert_eq!(fallback.backoff_delay(2), Duration::from_millis(400));
assert_eq!(fallback.backoff_delay(3), Duration::from_millis(500));
}
#[tokio::test]
async fn stream_retry_on_transient() {
let provider = TestProvider::with_responses(
"alpha",
vec![
Err(RunnerError::external_service("alpha", "503")),
Err(RunnerError::external_service("alpha", "503 again")),
],
);
let backup =
TestProvider::with_responses("beta", vec![Err(RunnerError::config("bad config"))]);
let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(provider), Box::new(backup)];
let retry = RetryConfig {
max_retries: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
};
let fallback = FallbackProvider::with_retry(providers, retry).expect("non-empty"); let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
match fallback.complete_stream(&request).await {
Err(err) => assert_eq!(err.kind, ErrorKind::Config),
Ok(_) => unreachable!("expected error"), }
}
}