arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Retry policy for the publish engine (RV2.9).
//!
//! Publishing is idempotent and resumable: an already-published target
//! version is verified and skipped; a partial run resumes from the first
//! missing crate; 429 (rate-limited) is retried with bounded backoff;
//! 401/403/build errors fail fast (ADR-0005 invariant 11).
//!
//! This module owns only the *classification* of outcomes and the retry
//! decision. It is pure and side-effect-free. The actual sleep (if any)
//! happens in the executor, not here.

/// The outcome of a single crate publish attempt.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PublishOutcome {
    /// The crate was successfully published.
    Success,
    /// The crate version already exists on the registry (idempotent
    /// resume — verified and skipped).
    AlreadyPublished,
    /// The registry returned a rate-limit (HTTP 429). Retriable with
    /// bounded backoff.
    RateLimited,
    /// Authentication or authorization failed (HTTP 401/403). Not
    /// retriable — fail fast.
    AuthFailed,
    /// The crate failed to build or pack. Not retriable — fail fast.
    BuildFailed,
    /// Any other failure that is not retriable.
    Failed,
}

/// The retry policy decision.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RetryDecision {
    /// Retry after this many milliseconds.
    Retry { delay_ms: u64, attempt: u32 },
    /// Do not retry — fail immediately.
    Fail,
}

/// The maximum number of retry attempts for a rate-limited publish.
#[allow(dead_code)]
const MAX_RETRIES: u32 = 3;

/// The base delay in milliseconds for the first retry. Subsequent
/// retries use exponential backoff: `base * 2^(attempt - 1)`.
#[allow(dead_code)]
const BASE_DELAY_MS: u64 = 2000;

/// Classify an HTTP status code into a publish outcome.
///
/// Known codes:
/// - 200/201 → Success
/// - 409 (already exists) → AlreadyPublished
/// - 429 → RateLimited
/// - 401/403 → AuthFailed
/// - 400 (build error from registry) → BuildFailed
/// - Other → Failed
#[allow(dead_code)]
pub(crate) fn classify_status(status: u16) -> PublishOutcome {
    match status {
        200 | 201 => PublishOutcome::Success,
        409 => PublishOutcome::AlreadyPublished,
        429 => PublishOutcome::RateLimited,
        401 | 403 => PublishOutcome::AuthFailed,
        400 => PublishOutcome::BuildFailed,
        _ => PublishOutcome::Failed,
    }
}

/// Decide whether to retry a rate-limited publish.
///
/// Returns `RetryDecision::Retry { delay_ms, attempt }` if the current
/// attempt is below the max-retries threshold, or `Fail` otherwise.
/// The delay follows exponential backoff: `BASE_DELAY_MS * 2^(attempt - 1)`.
#[allow(dead_code)]
pub(crate) fn decide_retry(outcome: &PublishOutcome, current_attempt: u32) -> RetryDecision {
    match outcome {
        PublishOutcome::RateLimited => {
            if current_attempt >= MAX_RETRIES {
                RetryDecision::Fail
            } else {
                let delay = BASE_DELAY_MS * (1u64 << (current_attempt - 1));
                RetryDecision::Retry {
                    delay_ms: delay,
                    attempt: current_attempt + 1,
                }
            }
        }
        PublishOutcome::Success
        | PublishOutcome::AlreadyPublished
        | PublishOutcome::AuthFailed
        | PublishOutcome::BuildFailed
        | PublishOutcome::Failed => RetryDecision::Fail,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_success() {
        assert_eq!(classify_status(200), PublishOutcome::Success);
        assert_eq!(classify_status(201), PublishOutcome::Success);
    }

    #[test]
    fn classify_already_published() {
        assert_eq!(classify_status(409), PublishOutcome::AlreadyPublished);
    }

    #[test]
    fn classify_rate_limited() {
        assert_eq!(classify_status(429), PublishOutcome::RateLimited);
    }

    #[test]
    fn classify_auth_failed() {
        assert_eq!(classify_status(401), PublishOutcome::AuthFailed);
        assert_eq!(classify_status(403), PublishOutcome::AuthFailed);
    }

    #[test]
    fn classify_build_failed() {
        assert_eq!(classify_status(400), PublishOutcome::BuildFailed);
    }

    #[test]
    fn classify_other() {
        assert_eq!(classify_status(500), PublishOutcome::Failed);
        assert_eq!(classify_status(404), PublishOutcome::Failed);
    }

    #[test]
    fn retry_rate_limited_first_attempt() {
        let decision = decide_retry(&PublishOutcome::RateLimited, 1);
        assert_eq!(
            decision,
            RetryDecision::Retry {
                delay_ms: 2000,
                attempt: 2
            }
        );
    }

    #[test]
    fn retry_rate_limited_second_attempt() {
        let decision = decide_retry(&PublishOutcome::RateLimited, 2);
        assert_eq!(
            decision,
            RetryDecision::Retry {
                delay_ms: 4000,
                attempt: 3
            }
        );
    }

    #[test]
    fn retry_rate_limited_max_attempts_fails() {
        let decision = decide_retry(&PublishOutcome::RateLimited, 3);
        assert_eq!(decision, RetryDecision::Fail);
    }

    #[test]
    fn retry_rate_limited_beyond_max_fails() {
        let decision = decide_retry(&PublishOutcome::RateLimited, 4);
        assert_eq!(decision, RetryDecision::Fail);
    }

    #[test]
    fn non_rate_limited_outcomes_fail_immediately() {
        assert_eq!(
            decide_retry(&PublishOutcome::Success, 1),
            RetryDecision::Fail
        );
        assert_eq!(
            decide_retry(&PublishOutcome::AuthFailed, 1),
            RetryDecision::Fail
        );
        assert_eq!(
            decide_retry(&PublishOutcome::BuildFailed, 1),
            RetryDecision::Fail
        );
        assert_eq!(
            decide_retry(&PublishOutcome::Failed, 1),
            RetryDecision::Fail
        );
        assert_eq!(
            decide_retry(&PublishOutcome::AlreadyPublished, 1),
            RetryDecision::Fail
        );
    }
}