use std::time::{Duration, Instant};
use crate::classify::ErrorPredicate;
use crate::core::Core;
use crate::error::{BreakerState, ExecutionError};
use crate::policy::ExecutionPolicy;
#[derive(Debug, Clone)]
pub struct Served<T> {
pub value: T,
pub target: String,
pub attempts: u32,
}
#[derive(Debug)]
pub enum FallbackError<E> {
AllUnavailable { next_available_at: Option<Instant> },
Exhausted(E),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selection {
FirstHealthy,
}
struct Target<C, T, E> {
id: String,
policy: ExecutionPolicy<C, T, E>,
}
pub struct FallbackPolicy<C, T, E> {
targets: Vec<Target<C, T, E>>,
selection: Selection,
advance_when: ErrorPredicate<E>,
deadline: Duration,
}
pub struct FallbackPolicyBuilder<C, T, E> {
targets: Vec<Target<C, T, E>>,
selection: Selection,
advance_when: Option<ErrorPredicate<E>>,
deadline: Duration,
}
impl<C, T, E> std::fmt::Debug for FallbackPolicy<C, T, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FallbackPolicy")
.field(
"targets",
&self.targets.iter().map(|t| &t.id).collect::<Vec<_>>(),
)
.field("selection", &self.selection)
.field("advance_when", &"<fn>")
.field("deadline", &self.deadline)
.finish()
}
}
impl<C, T, E> std::fmt::Debug for FallbackPolicyBuilder<C, T, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FallbackPolicyBuilder")
.field(
"targets",
&self.targets.iter().map(|t| &t.id).collect::<Vec<_>>(),
)
.field("selection", &self.selection)
.field("advance_when", &self.advance_when.as_ref().map(|_| "<fn>"))
.field("deadline", &self.deadline)
.finish()
}
}
impl<C, T, E> FallbackPolicy<C, T, E> {
pub fn builder() -> FallbackPolicyBuilder<C, T, E> {
FallbackPolicyBuilder {
targets: Vec::new(),
selection: Selection::FirstHealthy,
advance_when: None,
deadline: Duration::from_secs(3600),
}
}
pub fn selection(&self) -> Selection {
self.selection
}
pub fn deadline(&self) -> Duration {
self.deadline
}
}
impl<C, T, E> FallbackPolicyBuilder<C, T, E> {
pub fn target(mut self, id: impl Into<String>, policy: ExecutionPolicy<C, T, E>) -> Self {
self.targets.push(Target {
id: id.into(),
policy,
});
self
}
pub fn select(mut self, s: Selection) -> Self {
self.selection = s;
self
}
pub fn advance_when(mut self, f: impl Fn(&E) -> bool + Send + Sync + 'static) -> Self {
self.advance_when = Some(Box::new(f));
self
}
pub fn deadline(mut self, d: Duration) -> Self {
self.deadline = d;
self
}
pub fn build(self) -> FallbackPolicy<C, T, E> {
FallbackPolicy {
targets: self.targets,
selection: self.selection,
advance_when: self.advance_when.unwrap_or_else(|| Box::new(|_| true)),
deadline: self.deadline,
}
}
}
impl<C, T, E> FallbackPolicy<C, T, E>
where
C: Core,
{
pub async fn run<F>(&self, mut op: F) -> Result<Served<T>, FallbackError<E>>
where
F: AsyncFnMut(&str) -> Result<T, E>,
{
let Selection::FirstHealthy = self.selection;
let mut attempts = 0u32;
let mut soonest: Option<Instant> = None;
let mut last_err: Option<E> = None;
for target in &self.targets {
if matches!(target.policy.circuit_state(), Some(BreakerState::Open)) {
if let Some(until) = target.policy.cooling_until() {
soonest = Some(soonest.map_or(until, |s| s.min(until)));
}
continue;
}
attempts += 1;
let id = target.id.clone();
match target.policy.run(async || op(id.as_str()).await).await {
Ok(value) => {
return Ok(Served {
value,
target: id,
attempts,
});
}
Err(e) => match &e {
ExecutionError::Operation { source, .. } if !(self.advance_when)(source) => {
return Err(FallbackError::Exhausted(
e.into_inner().expect("Operation variant carries a source"),
));
}
_ => {
last_err = e.into_inner().or(last_err);
}
},
}
}
match last_err {
Some(e) => Err(FallbackError::Exhausted(e)),
None => Err(FallbackError::AllUnavailable {
next_available_at: soonest,
}),
}
}
}
#[cfg(all(test, feature = "test-util"))]
mod tests {
use super::*;
use crate::breaker::CircuitBreaker;
use crate::builder::ExecutionPolicyBuilder;
use crate::core::{ManualClock, TestCore};
use crate::retry::Retry;
use std::sync::atomic::{AtomicBool, Ordering};
fn policy(clock: &ManualClock) -> ExecutionPolicy<TestCore, u32, u16> {
ExecutionPolicyBuilder::<u32, u16>::new()
.retry(Retry::exponential().max_attempts(1))
.build_with(TestCore::new(clock.clone()))
}
fn tripping_policy(clock: &ManualClock) -> ExecutionPolicy<TestCore, u32, u16> {
ExecutionPolicyBuilder::<u32, u16>::new()
.retry(Retry::exponential().max_attempts(1))
.circuit_breaker(
CircuitBreaker::consecutive_failures(1).open_for(Duration::from_secs(47)),
)
.build_with(TestCore::new(clock.clone()))
}
#[tokio::test]
async fn first_healthy_target_serves_and_records_provenance() {
let clock = ManualClock::new();
let router = FallbackPolicy::builder()
.target("deepseek", policy(&clock))
.target("glm", policy(&clock))
.select(Selection::FirstHealthy)
.advance_when(|e: &u16| *e == 429 || *e >= 500)
.deadline(Duration::from_secs(3600))
.build();
let served = router
.run(async |target: &str| {
assert_eq!(target, "deepseek"); Ok::<u32, u16>(7)
})
.await
.expect("served");
assert_eq!(served.value, 7);
assert_eq!(served.target, "deepseek");
assert_eq!(served.attempts, 1);
}
#[tokio::test]
async fn permanent_error_does_not_advance_the_chain() {
let clock = ManualClock::new();
let router = FallbackPolicy::builder()
.target("a", policy(&clock))
.target("b", policy(&clock))
.select(Selection::FirstHealthy)
.advance_when(|e: &u16| *e == 429 || *e >= 500) .deadline(Duration::from_secs(3600))
.build();
let b_called = AtomicBool::new(false);
let out = router
.run(async |target: &str| {
if target == "b" {
b_called.store(true, Ordering::SeqCst);
}
Err::<u32, u16>(400) })
.await;
assert!(matches!(out, Err(FallbackError::Exhausted(400))));
assert!(
!b_called.load(Ordering::SeqCst),
"permanent error on 'a' must NOT advance to 'b'"
);
}
#[tokio::test]
async fn all_targets_open_returns_park_hint() {
let clock = ManualClock::new();
let router = FallbackPolicy::builder()
.target("a", tripping_policy(&clock))
.target("b", tripping_policy(&clock))
.select(Selection::FirstHealthy)
.advance_when(|_e: &u16| true)
.deadline(Duration::from_secs(3600))
.build();
let _ = router.run(|_t: &str| async { Err::<u32, u16>(500) }).await;
let out = router.run(|_t: &str| async { Ok::<u32, u16>(1) }).await;
match out {
Err(FallbackError::AllUnavailable {
next_available_at: Some(_),
}) => {}
other => panic!("expected AllUnavailable with a hint, got {other:?}"),
}
}
}