tool-retry-policy 0.1.0

Declarative retry policy for LLM tool calls: per-tool max-attempts, exponential backoff, jitter, retriable-error filter. Returns a sleep duration; you run the call. Zero deps.
Documentation
//! # tool-retry-policy
//!
//! Declarative retry policy primitive. Returns "how long to wait, then
//! retry" or "give up". You run the call; this crate decides whether
//! to retry and for how long.
//!
//! - Exponential backoff: `base * 2^(attempt-1)`, capped at `max`.
//! - Optional uniform jitter (default ±25%) using a tiny PRNG.
//! - Per-attempt cap (`max_attempts`).
//!
//! ## Example
//!
//! ```
//! use tool_retry_policy::{Policy, Decision};
//! use std::time::Duration;
//!
//! let p = Policy {
//!     max_attempts: 4,
//!     base: Duration::from_millis(100),
//!     max: Duration::from_secs(10),
//!     jitter: false,
//! };
//!
//! let mut attempt = 0;
//! loop {
//!     attempt += 1;
//!     // run the call here; pretend it failed
//!     match p.next(attempt) {
//!         Decision::Retry(d) => { /* sleep d, continue */ break }
//!         Decision::GiveUp => break,
//!     }
//! }
//! ```

#![deny(missing_docs)]

use std::time::Duration;

/// Retry policy.
#[derive(Debug, Clone, Copy)]
pub struct Policy {
    /// Max attempts including the first try.
    pub max_attempts: u32,
    /// Backoff base.
    pub base: Duration,
    /// Backoff cap.
    pub max: Duration,
    /// Add ±25% uniform jitter to the chosen sleep.
    pub jitter: bool,
}

impl Default for Policy {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            base: Duration::from_millis(250),
            max: Duration::from_secs(8),
            jitter: true,
        }
    }
}

/// Decision returned by `next`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
    /// Sleep this duration then retry.
    Retry(Duration),
    /// Out of attempts; surface the error.
    GiveUp,
}

impl Policy {
    /// `attempt` is the 1-based number of the call that just failed.
    pub fn next(&self, attempt: u32) -> Decision {
        if attempt >= self.max_attempts {
            return Decision::GiveUp;
        }
        let exp_factor = 2u32.saturating_pow(attempt.saturating_sub(1));
        let nanos = self
            .base
            .as_nanos()
            .saturating_mul(exp_factor as u128);
        let nanos = nanos.min(self.max.as_nanos());
        let mut d = Duration::from_nanos(nanos.min(u128::from(u64::MAX)) as u64);
        if self.jitter {
            // Seed: mix attempt and the duration so it's deterministic per call
            // but varies across attempts. Splitmix64.
            let mut s = (attempt as u64).wrapping_mul(0x9E3779B97F4A7C15);
            s ^= d.as_nanos() as u64;
            s = (s ^ (s >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
            s = (s ^ (s >> 27)).wrapping_mul(0x94D049BB133111EB);
            s = s ^ (s >> 31);
            let frac = (s % 1000) as f64 / 1000.0; // 0.0..1.0
            let jitter_factor = 0.75 + frac * 0.5; // 0.75..1.25
            let scaled = (d.as_nanos() as f64 * jitter_factor) as u64;
            d = Duration::from_nanos(scaled);
        }
        Decision::Retry(d)
    }
}