specado-core-temp 0.2.2

Core engine for Specado, a provider-neutral AI orchestration SDK.
Documentation
use crate::error::Result;
use std::future::Future;
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct RetryPolicy {
    max_attempts: usize,
    base_delay: Duration,
    max_delay: Duration,
}

impl RetryPolicy {
    pub fn new(max_attempts: usize, base_delay: Duration, max_delay: Duration) -> Self {
        Self {
            max_attempts: max_attempts.max(1),
            base_delay,
            max_delay,
        }
    }

    pub fn max_attempts(&self) -> usize {
        self.max_attempts
    }

    fn backoff_delay(&self, attempt: usize) -> Duration {
        if attempt <= 1 {
            return self.base_delay.min(self.max_delay);
        }

        let mut delay = self.base_delay;
        for _ in 1..attempt {
            delay = delay.checked_mul(2).unwrap_or(self.max_delay);
            if delay >= self.max_delay {
                return self.max_delay;
            }
        }

        delay.min(self.max_delay)
    }

    pub async fn execute<F, Fut, T>(&self, mut operation: F) -> Result<T>
    where
        F: FnMut() -> Fut,
        Fut: Future<Output = Result<T>> + Send,
        T: Send,
    {
        let mut attempt = 0;
        loop {
            attempt += 1;
            match operation().await {
                Ok(value) => return Ok(value),
                Err(err) => {
                    if attempt >= self.max_attempts {
                        return Err(err);
                    }
                    let delay = self.backoff_delay(attempt);
                    if !delay.is_zero() {
                        tokio::time::sleep(delay).await;
                    } else {
                        tokio::task::yield_now().await;
                    }
                }
            }
        }
    }
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            base_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(10),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;
    use std::sync::{Arc, Mutex};

    #[tokio::test]
    async fn retries_until_success() {
        let policy = RetryPolicy::new(3, Duration::from_millis(0), Duration::from_millis(0));
        let attempts = Arc::new(Mutex::new(0));
        let result = policy
            .execute({
                let attempts = attempts.clone();
                move || {
                    let attempts = attempts.clone();
                    Box::pin(async move {
                        let mut guard = attempts.lock().unwrap();
                        *guard += 1;
                        if *guard < 2 {
                            Err(Error::Transform("fail".into()))
                        } else {
                            Ok("ok")
                        }
                    })
                }
            })
            .await;

        assert_eq!(*attempts.lock().unwrap(), 2);
        assert_eq!(result.unwrap(), "ok");
    }

    #[tokio::test]
    async fn stops_after_max_attempts() {
        let policy = RetryPolicy::new(2, Duration::from_millis(0), Duration::from_millis(0));
        let attempts = Arc::new(Mutex::new(0));
        let result: Result<()> = policy
            .execute({
                let attempts = attempts.clone();
                move || {
                    let attempts = attempts.clone();
                    Box::pin(async move {
                        let mut guard = attempts.lock().unwrap();
                        *guard += 1;
                        Err(Error::Transform("still failing".into()))
                    })
                }
            })
            .await;

        assert!(result.is_err());
        assert_eq!(*attempts.lock().unwrap(), 2);
    }

    #[test]
    fn backoff_caps_at_max_delay() {
        let policy = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_millis(350));

        assert_eq!(policy.backoff_delay(1), Duration::from_millis(100));
        assert_eq!(policy.backoff_delay(2), Duration::from_millis(200));
        assert_eq!(policy.backoff_delay(3), Duration::from_millis(350));
        assert_eq!(policy.backoff_delay(4), Duration::from_millis(350));
    }
}