metabase 0.1.1

Ergonomic Rust SDK for Metabase's HTTP API, with async and blocking clients.
Documentation
use std::{
    cmp,
    time::{Duration, SystemTime},
};

use http::{HeaderMap, Method, StatusCode};

use crate::client::{Jitter, RetryPolicy};

pub(crate) fn is_retryable_status(status: StatusCode) -> bool {
    matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}

pub(crate) fn is_idempotent_method(method: &Method) -> bool {
    matches!(
        *method,
        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
    )
}

pub(crate) fn retry_after(headers: &HeaderMap) -> Option<Duration> {
    let value = headers.get("retry-after")?;
    let value = value.to_str().ok()?;
    let value = value.trim();

    if value.is_empty() {
        return None;
    }

    if let Ok(secs) = value.parse::<u64>() {
        return Some(Duration::from_secs(secs));
    }

    let date = httpdate::parse_http_date(value).ok()?;
    let now = SystemTime::now();
    let delay = date.duration_since(now).unwrap_or(Duration::from_secs(0));
    Some(delay)
}

pub(crate) fn next_delay(policy: &RetryPolicy, attempt: usize) -> Duration {
    if policy.max_retries == 0 {
        return Duration::from_secs(0);
    }

    let shift = cmp::min(attempt, 31);
    let factor = 1u32 << shift;

    let mut delay = policy.base_delay.saturating_mul(factor);
    if delay > policy.max_delay {
        delay = policy.max_delay;
    }

    match policy.jitter {
        Jitter::None => delay,
        Jitter::Full => full_jitter(delay),
    }
}

fn full_jitter(delay: Duration) -> Duration {
    let max_ms = u64::try_from(cmp::min(delay.as_millis(), u128::from(u64::MAX))).unwrap_or(0);
    if max_ms == 0 {
        return Duration::from_secs(0);
    }
    Duration::from_millis(fastrand::u64(0..=max_ms))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error as StdError;

    #[test]
    fn retry_after_parses_seconds() -> Result<(), Box<dyn StdError + Send + Sync>> {
        let mut headers = HeaderMap::new();
        headers.insert("Retry-After", http::HeaderValue::from_static("120"));
        assert_eq!(retry_after(&headers), Some(Duration::from_secs(120)));
        Ok(())
    }

    #[test]
    fn retry_after_parses_http_date() -> Result<(), Box<dyn StdError + Send + Sync>> {
        let mut headers = HeaderMap::new();
        let value = httpdate::fmt_http_date(SystemTime::UNIX_EPOCH);
        headers.insert("Retry-After", http::HeaderValue::from_str(&value)?);
        assert_eq!(retry_after(&headers), Some(Duration::from_secs(0)));
        Ok(())
    }

    #[test]
    fn next_delay_is_exponential_and_clamped_without_jitter() {
        let policy = RetryPolicy::conservative()
            .max_retries(10)
            .base_delay(Duration::from_millis(100))
            .max_delay(Duration::from_millis(500))
            .jitter(Jitter::None);

        assert_eq!(next_delay(&policy, 0), Duration::from_millis(100));
        assert_eq!(next_delay(&policy, 1), Duration::from_millis(200));
        assert_eq!(next_delay(&policy, 2), Duration::from_millis(400));
        assert_eq!(next_delay(&policy, 3), Duration::from_millis(500));
        assert_eq!(next_delay(&policy, 10), Duration::from_millis(500));
    }

    #[test]
    fn retryable_statuses_match_expectations() {
        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
        assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
        assert!(!is_retryable_status(StatusCode::BAD_REQUEST));
    }

    #[test]
    fn idempotent_methods_match_expectations() {
        assert!(is_idempotent_method(&Method::GET));
        assert!(is_idempotent_method(&Method::DELETE));
        assert!(!is_idempotent_method(&Method::POST));
    }
}