anilist_moe 0.4.0

Anilist_Moe is a Rust Wrapper for the Anilist API. This library allows you to seamlessly interact with Anilist's Public API with and without authentication. This currently supports Anime, Manga, Users, Staff, Forum, Threads, Recommendations, Reviews and User Activities
Documentation
//! Utility helpers for rate limits, retries, and JSON.

use crate::errors::AniListError;
use std::time::Duration;
use tokio::time::sleep;

/// Retry configuration.
///
/// Controls retry count, delay strategy, and backoff.
///
/// Example:
/// ```rust
/// use anilist_moe::utils::RetryConfig;
/// let config = RetryConfig {
///     max_retries: 5,
///     base_delay_ms: 2000,
///     exponential_backoff: true,
///     max_delay_ms: 60000,
///     use_jitter: true,
///     retry_on_server_error: true,
/// };
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryConfig {
    /// Maximum number of retry attempts before giving up
    pub max_retries: u32,
    /// Initial delay in milliseconds before the first retry
    pub base_delay_ms: u64,
    /// Whether to use exponential backoff (doubles delay each retry)
    pub exponential_backoff: bool,
    /// Maximum delay in milliseconds between retries
    pub max_delay_ms: u64,
    /// Whether to use jitter (adds/subtracts a random amount of delay to avoid synchronized retries)
    pub use_jitter: bool,
    /// Whether to retry on transient 5xx server errors
    pub retry_on_server_error: bool,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_retries: 3,
            base_delay_ms: 1000,
            exponential_backoff: true,
            max_delay_ms: 30000,
            use_jitter: false,
            retry_on_server_error: false,
        }
    }
}

impl RetryConfig {
    /// Configuration tuned for API rate limits.
    #[must_use]
    pub fn for_rate_limits() -> Self {
        Self {
            max_retries: 5,
            base_delay_ms: 60000,       // 1 minute base delay for rate limits
            exponential_backoff: false, // Fixed delay for rate limits
            max_delay_ms: 120000,       // 2 minute max
            use_jitter: false,
            retry_on_server_error: false,
        }
    }

    /// Configuration for aggressive retries (transient errors).
    #[must_use]
    pub fn aggressive() -> Self {
        Self {
            max_retries: 5,
            base_delay_ms: 500,
            exponential_backoff: true,
            max_delay_ms: 10000,
            use_jitter: false,
            retry_on_server_error: false,
        }
    }

    /// Calculates the delay for a given attempt number.
    #[inline]
    fn calculate_delay(&self, attempts: u32) -> Duration {
        let delay = if self.exponential_backoff {
            self.base_delay_ms.saturating_mul(1u64 << attempts.min(10))
        } else {
            self.base_delay_ms
        };
        let duration = Duration::from_millis(delay.min(self.max_delay_ms));
        if self.use_jitter {
            add_jitter(duration)
        } else {
            duration
        }
    }
}

/// Retry an operation with backoff on rate limits.
pub async fn retry_with_backoff<F, Fut, T>(
    mut operation: F,
    config: RetryConfig,
) -> Result<T, AniListError>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, AniListError>>,
{
    let mut attempts = 0u32;

    loop {
        match operation().await {
            Ok(result) => return Ok(result),
            Err(err) => {
                let (should_retry, sleep_duration) = match &err {
                    AniListError::RateLimit { retry_after, .. } => {
                        if attempts >= config.max_retries {
                            return Err(err);
                        }
                        // Use the Retry-After header if available and reasonable
                        let duration = if *retry_after > 0 && *retry_after <= 300 {
                            let d = Duration::from_secs(*retry_after as u64);
                            if config.use_jitter { add_jitter(d) } else { d }
                        } else {
                            config.calculate_delay(attempts)
                        };
                        (true, duration)
                    }
                    AniListError::RateLimitSimple => {
                        if attempts >= config.max_retries {
                            return Err(err);
                        }
                        (true, config.calculate_delay(attempts))
                    }
                    AniListError::BurstLimit => {
                        if attempts >= config.max_retries {
                            return Err(err);
                        }
                        // For burst limits, wait longer
                        let duration = config.calculate_delay(attempts + 1);
                        (true, duration)
                    }
                    AniListError::ServerError { .. }
                        if config.retry_on_server_error && attempts < config.max_retries =>
                    {
                        (true, config.calculate_delay(attempts))
                    }
                    // Don't retry on other errors
                    _ => return Err(err),
                };

                if should_retry {
                    log::warn!(
                        "Rate limited. Retrying in {} seconds... (attempt {}/{})",
                        sleep_duration.as_secs(),
                        attempts + 1,
                        config.max_retries
                    );
                    crate::trace_warn!(
                        attempt = attempts + 1,
                        max_retries = config.max_retries,
                        delay_secs = sleep_duration.as_secs(),
                        "Rate limited. Retrying..."
                    );

                    sleep(sleep_duration).await;
                    attempts += 1;
                }
            }
        }
    }
}

/// Adds pseudo-random jitter (±20%) to a duration.
#[inline]
pub fn add_jitter(duration: Duration) -> Duration {
    use std::time::SystemTime;
    let seed = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(1337) as u64;

    // Simple LCG parameters
    let a = 6364136223846793005u64;
    let c = 1442695040888963407u64;
    let rand_val = seed.wrapping_mul(a).wrapping_add(c);

    let duration_ms = duration.as_millis() as u64;
    let jittered_ms = duration_ms.saturating_mul(80 + (rand_val % 41)) / 100;

    Duration::from_millis(jittered_ms.max(1))
}

/// Sleeps for the specified duration in milliseconds.
#[inline]
pub async fn rate_limit_delay(delay_ms: u64) {
    sleep(Duration::from_millis(delay_ms)).await;
}

/// Calculates an appropriate delay based on remaining rate limit quota.
#[inline]
pub fn calculate_delay(remaining: u32, reset_in_seconds: u64) -> Duration {
    match remaining {
        0 => Duration::from_secs(reset_in_seconds),
        1..=9 => Duration::from_millis(2000), // 2 seconds when getting low
        10..=29 => Duration::from_millis(1000), // 1 second when moderate
        _ => Duration::from_millis(500),      // 500ms when plenty remaining
    }
}

#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! trace_span {
    ($name:expr) => {
        let _span = tracing::info_span!($name).entered();
    };
    ($name:expr, $($fields:tt)*) => {
        let _span = tracing::info_span!($name, $($fields)*).entered();
    };
}

#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! trace_span {
    ($name:expr) => {};
    ($name:expr, $($fields:tt)*) => {};
}

#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! trace_info {
    ($($arg:tt)*) => {
        tracing::info!($($arg)*);
    };
}

#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! trace_info {
    ($($arg:tt)*) => {};
}

#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! trace_warn {
    ($($arg:tt)*) => {
        tracing::warn!($($arg)*);
    };
}

#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! trace_warn {
    ($($arg:tt)*) => {};
}

#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! trace_error {
    ($($arg:tt)*) => {
        tracing::error!($($arg)*);
    };
}

#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! trace_error {
    ($($arg:tt)*) => {};
}