invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation
//! When to wait before a retry.

use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
use std::time::Duration;

const BASE: Duration = Duration::from_millis(500);
const CAP: Duration = Duration::from_secs(8);

/// A `Retry-After` longer than this isn't waited for: the call fails at once
/// with that response's error, so a program never hangs for an hour.
pub(crate) const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);

/// `Retry-After` as whole seconds. The API sends an integer; anything else
/// (an HTTP date, a negative number) falls back to the backoff schedule.
pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> {
    let value = value.trim();
    if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    value.parse::<u64>().ok().map(Duration::from_secs)
}

/// The wait before retry number `attempt + 1`: what `Retry-After` asked
/// for, otherwise `500ms × 2^attempt` with ±20% jitter, capped at 8s.
pub(crate) fn delay(attempt: u32, retry_after: Option<Duration>) -> Duration {
    if let Some(wait) = retry_after {
        return wait;
    }
    backoff(attempt, random_unit())
}

/// The backoff for `attempt`, with `unit` in `[0, 1)` choosing the jitter.
pub(crate) fn backoff(attempt: u32, unit: f64) -> Duration {
    let base = BASE.saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX));
    let factor = 0.8 + 0.4 * unit.clamp(0.0, 1.0);
    base.mul_f64(factor).min(CAP)
}

/// A number in `[0, 1)`, random enough for jitter. `RandomState` is seeded
/// afresh per instance, so this needs no RNG dependency.
fn random_unit() -> f64 {
    let bits = RandomState::new().hash_one(std::time::Instant::now());
    (bits >> 11) as f64 / (1u64 << 53) as f64
}

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

    #[test]
    fn backoff_doubles_with_jitter_and_caps() {
        assert_eq!(backoff(0, 0.5), Duration::from_millis(500));
        assert_eq!(backoff(1, 0.5), Duration::from_millis(1000));
        assert_eq!(backoff(2, 0.5), Duration::from_millis(2000));
        assert_eq!(backoff(0, 0.0), Duration::from_millis(400));
        assert_eq!(backoff(0, 1.0), Duration::from_millis(600));
        assert_eq!(backoff(4, 1.0), CAP);
        assert_eq!(backoff(40, 0.5), CAP);
    }

    #[test]
    fn jitter_stays_in_range() {
        for attempt in 0..6 {
            for _ in 0..200 {
                let d = delay(attempt, None);
                let nominal = BASE * 2u32.pow(attempt);
                assert!(d >= nominal.mul_f64(0.8).min(CAP), "{d:?} too short");
                assert!(d <= nominal.mul_f64(1.2).min(CAP), "{d:?} too long");
            }
        }
    }

    #[test]
    fn retry_after_wins_and_must_be_an_integer() {
        assert_eq!(parse_retry_after("3"), Some(Duration::from_secs(3)));
        assert_eq!(parse_retry_after(" 0 "), Some(Duration::ZERO));
        assert_eq!(parse_retry_after("-1"), None);
        assert_eq!(parse_retry_after("1.5"), None);
        assert_eq!(parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT"), None);
        assert_eq!(
            delay(0, Some(Duration::from_secs(1))),
            Duration::from_secs(1)
        );
    }
}