bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Error classification and deterministic bounded retry policy.

use std::time::Duration;

use crate::error::ForgeError;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Retry classification derived from structured error categories and known command diagnostics.
pub(crate) enum ErrorClass {
    /// The same idempotent operation may succeed when attempted again.
    Transient,
    /// Retrying unchanged inputs is not expected to help.
    Permanent,
    /// Content integrity or path-boundary verification failed.
    Integrity,
    /// Cancellation was requested.
    Cancelled,
}

/// Classify a forge error for retry decisions.
pub(crate) fn classify(error: &ForgeError) -> ErrorClass {
    if matches!(error, ForgeError::Network(_)) {
        return ErrorClass::Transient;
    }
    let message = error.to_string().to_ascii_lowercase();
    if message.contains("ctrl-c") || message.contains("cancel") {
        ErrorClass::Cancelled
    } else if message.contains("sha-256")
        || message.contains("checksum")
        || message.contains("verification")
        || message.contains("escapes")
    {
        ErrorClass::Integrity
    } else if message.contains("timed out")
        || message.contains("timeout")
        || message.contains("temporarily unavailable")
        || message.contains("spurious network")
        || message.contains("failed to download")
        || message.contains("connection reset")
        || message.contains("connection refused")
        || message.contains("http2 framing")
        || message.contains("dns error")
    {
        ErrorClass::Transient
    } else {
        ErrorClass::Permanent
    }
}

#[derive(Debug, Clone, Copy)]
/// Maximum attempts and exponential-backoff bounds for idempotent operations.
pub(crate) struct RetryPolicy {
    /// Total attempts including the initial operation.
    pub(crate) max_attempts: u32,
    /// Initial exponential delay.
    pub(crate) base_delay: Duration,
    /// Maximum exponential delay before deterministic jitter.
    pub(crate) max_delay: Duration,
}

impl RetryPolicy {
    /// Compute capped exponential backoff with deterministic seed-based jitter.
    pub(crate) fn delay(&self, attempt: u32, seed: u64) -> Duration {
        let factor = 1_u32.checked_shl(attempt.min(20)).unwrap_or(u32::MAX);
        let exponential = self.base_delay.saturating_mul(factor);
        let capped = exponential.min(self.max_delay);
        let jitter_millis = if capped.is_zero() {
            0
        } else {
            seed.wrapping_mul(6_364_136_223_846_793_005)
                .wrapping_add(u64::from(attempt))
                % (u64::try_from(capped.as_millis()).unwrap_or(u64::MAX) / 4 + 1)
        };
        capped.saturating_add(Duration::from_millis(jitter_millis))
    }

    /// Return whether another attempt is allowed for this idempotent transient operation.
    pub(crate) fn should_retry(&self, attempt: u32, idempotent: bool, error: &ForgeError) -> bool {
        idempotent && attempt + 1 < self.max_attempts && classify(error) == ErrorClass::Transient
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use crate::error::ForgeError;
    use crate::execution::retry::RetryPolicy;

    #[test]
    fn retries_only_transient_idempotent_operations() {
        let policy = RetryPolicy {
            max_attempts: 3,
            base_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(1),
        };
        assert!(policy.should_retry(0, true, &ForgeError::Network("timeout".into())));
        assert!(!policy.should_retry(0, false, &ForgeError::Network("timeout".into())));
        assert!(!policy.should_retry(0, true, &ForgeError::Config("invalid".into())));
        assert!(policy.should_retry(
            0,
            true,
            &ForgeError::Command("spurious network error: connection reset".into())
        ));
        assert!(!policy.should_retry(0, true, &ForgeError::Command("checksum mismatch".into())));
        assert!(!policy.should_retry(2, true, &ForgeError::Network("timeout".into())));
    }

    #[test]
    fn backoff_is_deterministic_and_capped() {
        let policy = RetryPolicy {
            max_attempts: 10,
            base_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(1),
        };
        assert_eq!(policy.delay(2, 42), policy.delay(2, 42));
        assert!(policy.delay(20, 42) <= Duration::from_millis(1_250));
    }
}