cnb 0.2.2

CNB (cnb.cool) API client for Rust — typed, async, production-ready
Documentation
//! Retry policy used by the central `execute` function.
//!
//! The policy is deliberately small: only idempotent HTTP methods are retried,
//! only on errors flagged by [`ApiError::is_retryable`](crate::ApiError::is_retryable),
//! and the back-off is exponential with jitter. `Retry-After` (when present on
//! a 429 / 503) overrides the computed delay.
//!
//! When the `retry` feature is disabled the [`RetryConfig`] type is still
//! available — it just defaults to `max_attempts = 1` so no retries happen.

use std::time::Duration;

/// Retry configuration. Construct with [`RetryConfig::default`] for sensible
/// defaults or adjust individual fields.
///
/// # Example
///
/// ```no_run
/// use cnb::{ApiClient, RetryConfig};
/// use std::time::Duration;
///
/// let client = ApiClient::builder()
///     .retry(RetryConfig {
///         max_attempts: 5,
///         base_delay: Duration::from_millis(100),
///         max_delay: Duration::from_secs(10),
///     })
///     .build()
///     .unwrap();
/// # let _ = client;
/// ```
#[derive(Debug, Clone, Copy)]
pub struct RetryConfig {
    /// Maximum total number of attempts (i.e. `1` disables retries).
    pub max_attempts: u32,
    /// Base delay for exponential back-off. Effective delay is
    /// `base_delay * 2^(attempt - 1)` plus deterministic jitter, capped by
    /// [`Self::max_delay`].
    pub base_delay: Duration,
    /// Hard cap on a single back-off interval.
    pub max_delay: Duration,
}

impl Default for RetryConfig {
    fn default() -> Self {
        // 3 attempts × ~200ms × 2^n keeps total wait under ~1s on the worst
        // path while still catching the vast majority of flaky-network errors.
        #[cfg(feature = "retry")]
        {
            Self {
                max_attempts: 3,
                base_delay: Duration::from_millis(200),
                max_delay: Duration::from_secs(5),
            }
        }
        #[cfg(not(feature = "retry"))]
        {
            Self {
                max_attempts: 1,
                base_delay: Duration::from_millis(0),
                max_delay: Duration::from_millis(0),
            }
        }
    }
}

impl RetryConfig {
    /// Disable retries entirely.
    pub fn disabled() -> Self {
        Self {
            max_attempts: 1,
            base_delay: Duration::from_millis(0),
            max_delay: Duration::from_millis(0),
        }
    }

    /// Compute the back-off delay for a given (1-based) attempt number.
    /// Caller is responsible for honouring an upstream `Retry-After` instead.
    pub(crate) fn backoff_for(&self, attempt: u32) -> Duration {
        if attempt == 0 {
            return Duration::from_millis(0);
        }
        // Exponential: base * 2^(attempt-1). Use saturating arithmetic to avoid
        // overflow on degenerate configs.
        let exp = 2u64.saturating_pow(attempt.saturating_sub(1));
        let delay = self
            .base_delay
            .checked_mul(exp.min(u32::MAX as u64) as u32)
            .unwrap_or(self.max_delay);
        // Deterministic jitter derived from the attempt number — no rand
        // dependency, but enough variation to avoid thundering-herd retries on
        // synchronised clients.
        let jitter_ms = (attempt as u64 * 37) % 50;
        let jittered = delay.saturating_add(Duration::from_millis(jitter_ms));
        jittered.min(self.max_delay)
    }
}

/// True for HTTP methods we consider safe to retry by default. POST / PATCH are
/// excluded because their server-side semantics are typically non-idempotent.
pub(crate) fn is_idempotent_method(method: &reqwest::Method) -> bool {
    matches!(
        *method,
        reqwest::Method::GET
            | reqwest::Method::HEAD
            | reqwest::Method::PUT
            | reqwest::Method::DELETE
            | reqwest::Method::OPTIONS
    )
}