jev-sdk 0.1.0

Unofficial Rust client for the TypeSafe AI System One API (Jev): typed decisions with calibrated probabilities
Documentation
//! Retry behavior for transient failures.

use reqwest::header::HeaderMap;
use reqwest::StatusCode;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

/// Controls how failed requests are retried.
///
/// By default the SDK retries up to 2 times on connection errors and on the
/// statuses listed in [`RetryPolicy::retryable_statuses`] (which includes the
/// documented `429 Too Many Requests` and `529 Overloaded`), using exponential
/// backoff with jitter, and honoring the `Retry-After` response header.
///
/// Set [`RetryPolicy::none`] (or `max_retries: 0`) to disable retries.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
    /// Maximum number of retries after the initial attempt. `0` disables
    /// retries.
    pub max_retries: u32,
    /// Backoff before the first retry.
    pub initial_backoff: Duration,
    /// Upper bound for any single backoff.
    pub max_backoff: Duration,
    /// Multiplier applied to the backoff after each attempt.
    pub multiplier: f64,
    /// HTTP statuses considered transient.
    pub retryable_statuses: Vec<u16>,
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            max_retries: 2,
            initial_backoff: Duration::from_millis(500),
            max_backoff: Duration::from_secs(8),
            multiplier: 2.0,
            retryable_statuses: vec![408, 409, 425, 429, 500, 502, 503, 504, 529],
        }
    }
}

impl RetryPolicy {
    /// A policy that never retries.
    pub fn none() -> Self {
        Self {
            max_retries: 0,
            ..Self::default()
        }
    }

    /// Whether a response status is retryable under this policy.
    pub fn should_retry_status(&self, status: StatusCode) -> bool {
        self.retryable_statuses.contains(&status.as_u16())
    }

    /// The delay before retry attempt `attempt` (0 = first retry), including
    /// up to 25% random jitter and capped at [`RetryPolicy::max_backoff`].
    ///
    /// This function is total: non-finite or non-positive multipliers fall
    /// back to `2.0`, and overflow saturates at `max_backoff` instead of
    /// panicking.
    pub fn backoff_for(&self, attempt: u32) -> Duration {
        let multiplier = if self.multiplier.is_finite() && self.multiplier > 0.0 {
            self.multiplier
        } else {
            2.0
        };
        // Cheap jitter derived from the wall clock; avoids a `rand` dependency.
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.subsec_nanos())
            .unwrap_or(0);
        let jitter = 1.0 + (nanos % 250) as f64 / 1000.0; // 1.00 ..= 1.249

        // Cap the exponent so huge `attempt` values overflow fast into the
        // saturation branch below instead of producing inf * 0 edge cases.
        let secs =
            self.initial_backoff.as_secs_f64() * multiplier.powi(attempt.min(64) as i32) * jitter;

        let max_secs = self.max_backoff.as_secs_f64();
        if !secs.is_finite() || secs < 0.0 || secs >= max_secs {
            // `max_backoff` is a Duration already, so this never converts a
            // huge f64 back into a Duration.
            return self.max_backoff;
        }
        Duration::from_secs_f64(secs)
    }

    /// The delay to use for a failed response: the larger of the computed
    /// backoff and the server's `Retry-After` hint. The hint is capped at
    /// [`MAX_RETRY_AFTER`] so a hostile or buggy server cannot make the
    /// client sleep indefinitely.
    pub(crate) fn delay_for(&self, attempt: u32, headers: &HeaderMap) -> Duration {
        let backoff = self.backoff_for(attempt);
        match parse_retry_after(headers) {
            Some(hint) => backoff.max(hint.min(MAX_RETRY_AFTER)),
            None => backoff,
        }
    }
}

/// Upper bound for a server-provided `Retry-After` hint.
pub(crate) const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);

/// Parse a `Retry-After` header expressed in (fractional) seconds.
fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
    let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
    let secs: f64 = value.trim().parse().ok()?;
    (secs.is_finite() && secs >= 0.0).then(|| Duration::from_secs_f64(secs))
}

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

    #[test]
    fn defaults_retry_documented_statuses() {
        let p = RetryPolicy::default();
        assert!(p.should_retry_status(StatusCode::TOO_MANY_REQUESTS));
        assert!(p.should_retry_status(StatusCode::from_u16(529).unwrap()));
        assert!(!p.should_retry_status(StatusCode::BAD_REQUEST));
        assert!(!p.should_retry_status(StatusCode::UNAUTHORIZED));
    }

    #[test]
    fn backoff_grows_and_is_capped() {
        let p = RetryPolicy::default();
        let b0 = p.backoff_for(0);
        let b1 = p.backoff_for(1);
        assert!(b0 >= Duration::from_millis(500));
        assert!(b1 >= b0);
        let huge = p.backoff_for(30);
        assert!(huge <= Duration::from_secs(8));
    }

    #[test]
    fn backoff_never_panics_on_extreme_inputs() {
        let mut p = RetryPolicy::default();

        // Absurd attempt counts saturate instead of overflowing to infinity.
        assert_eq!(p.backoff_for(u32::MAX), Duration::from_secs(8));

        // Non-finite / non-positive multipliers fall back to 2.0.
        p.multiplier = f64::NAN;
        assert!(p.backoff_for(5) <= Duration::from_secs(8));
        p.multiplier = f64::INFINITY;
        assert!(p.backoff_for(5) <= Duration::from_secs(8));
        p.multiplier = -3.0;
        assert!(p.backoff_for(5) <= Duration::from_secs(8));

        // A zero cap yields zero delay.
        p.max_backoff = Duration::ZERO;
        assert_eq!(p.backoff_for(0), Duration::ZERO);
    }

    #[test]
    fn retry_after_hint_is_capped() {
        let p = RetryPolicy::default();
        let mut headers = HeaderMap::new();
        // A hostile server hint of ~31 years must not be honored.
        headers.insert(
            reqwest::header::RETRY_AFTER,
            HeaderValue::from_static("999999999"),
        );
        let delay = p.delay_for(0, &headers);
        assert!(
            delay <= MAX_RETRY_AFTER,
            "delay {delay:?} exceeded the cap {MAX_RETRY_AFTER:?}"
        );
    }

    #[test]
    fn parses_retry_after_seconds() {
        let mut headers = HeaderMap::new();
        headers.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("2"));
        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(2)));

        headers.insert(
            reqwest::header::RETRY_AFTER,
            HeaderValue::from_static("0.25"),
        );
        assert_eq!(
            parse_retry_after(&headers),
            Some(Duration::from_millis(250))
        );

        headers.insert(
            reqwest::header::RETRY_AFTER,
            HeaderValue::from_static("not-a-number"),
        );
        assert_eq!(parse_retry_after(&headers), None);
    }
}