linear-cli 0.2.1

A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal
use std::time::Duration;
use tokio::time::sleep;

/// Retry configuration for API calls
#[derive(Debug, Clone, Copy)]
pub struct RetryConfig {
    pub max_retries: u32,
    pub initial_delay_ms: u64,
    pub max_delay_ms: u64,
    pub exponential_base: f64,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_retries: 3,
            initial_delay_ms: 1000,
            max_delay_ms: 30000,
            exponential_base: 2.0,
        }
    }
}

impl RetryConfig {
    pub fn new(max_retries: u32) -> Self {
        Self {
            max_retries,
            ..Default::default()
        }
    }

    pub fn no_retry() -> Self {
        Self {
            max_retries: 0,
            ..Default::default()
        }
    }

    /// Calculate delay for a given attempt (0-indexed)
    pub fn delay_for_attempt(&self, attempt: u32, retry_after: Option<u64>) -> Duration {
        // If server specified retry-after, use that
        if let Some(seconds) = retry_after {
            return Duration::from_secs(seconds);
        }

        // Exponential backoff: initial_delay * base^attempt
        let delay_ms = (self.initial_delay_ms as f64 * self.exponential_base.powi(attempt as i32))
            .min(self.max_delay_ms as f64) as u64;

        Duration::from_millis(delay_ms)
    }
}

/// Execute a function with retry logic
pub async fn with_retry<F, Fut, T, E>(
    config: &RetryConfig,
    mut f: F,
) -> Result<T, E>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, E>>,
    E: std::fmt::Display + IsRetryable,
{
    let mut last_error: Option<E> = None;

    for attempt in 0..=config.max_retries {
        match f().await {
            Ok(result) => return Ok(result),
            Err(e) => {
                if attempt < config.max_retries && e.is_retryable() {
                    let retry_after = e.retry_after();
                    let delay = config.delay_for_attempt(attempt, retry_after);
                    eprintln!(
                        "Attempt {} failed: {}. Retrying in {:?}...",
                        attempt + 1,
                        e,
                        delay
                    );
                    sleep(delay).await;
                    last_error = Some(e);
                } else {
                    return Err(e);
                }
            }
        }
    }

    Err(last_error.expect("Should have an error after retries"))
}

/// Trait to determine if an error is retryable
pub trait IsRetryable {
    fn is_retryable(&self) -> bool;
    fn retry_after(&self) -> Option<u64>;
}

impl IsRetryable for anyhow::Error {
    fn is_retryable(&self) -> bool {
        let msg = self.to_string().to_lowercase();
        // Retry on rate limits, timeouts, and transient network errors
        msg.contains("rate limit")
            || msg.contains("429")
            || msg.contains("timeout")
            || msg.contains("connection")
            || msg.contains("temporarily unavailable")
            || msg.contains("503")
            || msg.contains("502")
            || msg.contains("504")
    }

    fn retry_after(&self) -> Option<u64> {
        // Try to extract retry_after from error chain
        // This is a simplified version - in practice we'd look at the CliError
        None
    }
}