polyc-llm 2026.9.0

Provider-agnostic LLM trait + wire types for polychrome.
Documentation
//! Backoff shared by every retry loop that dials a provider.
//!
//! The wait belongs beside [`LlmErrorKind`](crate::LlmErrorKind) and
//! [`LlmError::retry_after`](crate::LlmError::retry_after), because a caller
//! chooses between the two: it honors a server hint when one exists, and it
//! computes a wait when one does not. Two loops make that choice today. The
//! turn loop retries the provider dial. The model broker retries the same dial
//! under its own durable attempt identity. One definition keeps their spacing
//! identical.

use std::time::Duration;

/// Exponential backoff with equal jitter.
///
/// Half the delay is fixed and half is scaled by `jitter_frac` ∈ [0, 1), so the
/// wait lands in `[0.5, 1.0] × base × 2^attempt` (capped). Pure, so callers and
/// tests control the jitter.
#[must_use]
pub fn backoff_delay(attempt: u32, base: Duration, cap: Duration, jitter_frac: f64) -> Duration {
    // `2^attempt`, saturating so a large attempt can't panic on shift overflow.
    let factor = 1u32.checked_shl(attempt.min(16)).unwrap_or(u32::MAX);
    let exp = base.saturating_mul(factor).min(cap);
    // Equal jitter: 0.5 + 0.5*frac, written as a fused multiply-add.
    let scale = 0.5_f64.mul_add(jitter_frac.clamp(0.0, 1.0), 0.5);
    exp.mul_f64(scale)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use std::time::Duration;

    use super::backoff_delay;

    /// The wait doubles per attempt, and the jitter keeps it in the half band.
    #[test]
    fn backoff_doubles_and_stays_inside_the_equal_jitter_band() {
        let base = Duration::from_millis(500);
        let cap = Duration::from_secs(30);
        for attempt in 0..4 {
            let un_jittered = base * (1 << attempt);
            assert_eq!(backoff_delay(attempt, base, cap, 0.0), un_jittered / 2);
            assert_eq!(backoff_delay(attempt, base, cap, 1.0), un_jittered);
        }
    }

    /// The cap bounds one wait, and a large attempt never panics.
    #[test]
    fn backoff_caps_and_saturates() {
        let base = Duration::from_millis(500);
        let cap = Duration::from_secs(30);
        assert_eq!(backoff_delay(20, base, cap, 1.0), cap);
        assert_eq!(backoff_delay(u32::MAX, base, cap, 1.0), cap);
    }
}