bbm 0.1.1

Async Rust client library for the German Breitbandmessung (broadband measurement) API
Documentation
// Copyright (c) 2023-2026 Tim Oliver Rabl
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

use std::future::Future;
use std::pin::Pin;
use std::time::Duration;

use tower::retry::Policy;

use crate::error::BbmError;

/// Default maximum number of retry attempts.
pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;

/// Upper bound on a single backoff delay. Unbounded doubling reaches years,
/// which would hang a caller rather than failing.
pub const MAX_BACKOFF: Duration = Duration::from_secs(30);

/// A tower retry policy for transient HTTP failures with exponential backoff.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
    pub max_attempts: u32,
    pub current_attempt: u32,
}

impl RetryPolicy {
    /// Create a new retry policy with the given maximum number of attempts.
    pub fn new(max_attempts: u32) -> Self {
        Self {
            max_attempts,
            current_attempt: 0,
        }
    }

    /// Calculate the backoff duration for the current attempt using exponential
    /// backoff: 200ms * 2^(current_attempt - 1).
    pub fn backoff_duration(&self) -> Duration {
        let base = Duration::from_millis(200);
        // Saturate in u32 throughout: computing in u64 and casting truncates,
        // so 2^32 becomes 0 and the backoff collapses to nothing.
        let factor = 2u32.saturating_pow(self.current_attempt.saturating_sub(1));
        base.saturating_mul(factor).min(MAX_BACKOFF)
    }

    /// Determine whether the given error is retryable.
    ///
    /// Retries on `BbmError::Http` when the underlying reqwest error is a
    /// timeout, connect error, or a 5xx server error, and on `BbmError::Io`.
    /// Does NOT retry `Api`, `Json`, or `TestFailed`.
    pub fn is_retryable(err: &BbmError) -> bool {
        match err {
            BbmError::Http(e) => {
                if e.is_timeout() || e.is_connect() {
                    return true;
                }
                if let Some(status) = e.status() {
                    return status.is_server_error();
                }
                false
            }
            BbmError::Io(_) => true,
            BbmError::Api(_) | BbmError::Json(_) | BbmError::TestFailed(_) => false,
        }
    }
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self::new(DEFAULT_MAX_ATTEMPTS)
    }
}

impl<Req, Res> Policy<Req, Res, BbmError> for RetryPolicy
where
    Req: Clone,
{
    type Future = Pin<Box<dyn Future<Output = ()> + Send>>;

    fn retry(
        &mut self,
        _req: &mut Req,
        result: &mut Result<Res, BbmError>,
    ) -> Option<Self::Future> {
        match result {
            Ok(_) => None,
            Err(err) => {
                if !Self::is_retryable(err) {
                    return None;
                }

                if self.current_attempt >= self.max_attempts {
                    return None;
                }

                self.current_attempt += 1;
                let backoff = self.backoff_duration();

                log::warn!(
                    "Retryable error (attempt {}/{}), retrying after {:?}: {}",
                    self.current_attempt,
                    self.max_attempts,
                    backoff,
                    err,
                );

                Some(Box::pin(async move {
                    tokio::time::sleep(backoff).await;
                }))
            }
        }
    }

    fn clone_request(&mut self, req: &Req) -> Option<Req> {
        Some(req.clone())
    }
}

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

    /// `2u64.saturating_pow(..) as u32` truncates rather than saturating: at
    /// attempt 33 the factor is 2^32, which casts to 0, giving zero backoff --
    /// a hot retry loop. `max_attempts` is public, so a caller can reach this.
    #[test]
    fn backoff_saturates_instead_of_wrapping_to_zero() {
        let mut policy = RetryPolicy::new(64);
        policy.current_attempt = 33;

        let backoff = policy.backoff_duration();

        assert!(
            backoff >= Duration::from_millis(200),
            "backoff collapsed to {backoff:?}; it must never shrink below the base delay"
        );
    }

    /// Unbounded doubling is not a usable delay: by attempt 32 it is measured
    /// in years, which would hang a caller indefinitely rather than failing.
    #[test]
    fn backoff_is_capped() {
        let mut policy = RetryPolicy::new(64);
        policy.current_attempt = 40;

        let backoff = policy.backoff_duration();

        assert!(
            backoff <= Duration::from_secs(60),
            "backoff of {backoff:?} is unusable; it must be capped"
        );
    }

    /// Backoff must be monotonic -- never smaller than the previous attempt.
    #[test]
    fn backoff_never_decreases() {
        let mut prev = Duration::ZERO;
        for attempt in 1..=40 {
            let mut policy = RetryPolicy::new(64);
            policy.current_attempt = attempt;
            let backoff = policy.backoff_duration();
            assert!(
                backoff >= prev,
                "attempt {attempt}: backoff {backoff:?} < previous {prev:?}"
            );
            prev = backoff;
        }
    }

    #[test]
    fn retryable_errors() {
        // Io errors are retryable
        let io_err = BbmError::Io(std::io::Error::new(
            std::io::ErrorKind::ConnectionReset,
            "connection reset",
        ));
        assert!(RetryPolicy::is_retryable(&io_err));

        // Api errors are not retryable
        let api_err = BbmError::Api("bad request".into());
        assert!(!RetryPolicy::is_retryable(&api_err));

        // TestFailed errors are not retryable
        let test_err = BbmError::TestFailed("test failed".into());
        assert!(!RetryPolicy::is_retryable(&test_err));

        // Json errors are not retryable
        let json_err: BbmError = serde_json::from_str::<serde_json::Value>("not json")
            .unwrap_err()
            .into();
        assert!(!RetryPolicy::is_retryable(&json_err));

        // Http errors: we cannot easily construct specific reqwest::Error
        // variants (timeout, connect, 5xx) in unit tests since reqwest does
        // not expose constructors. We test the Http branch by making a request
        // with an invalid URL to get a builder error, which should not be retryable.
        let client = reqwest::Client::new();
        let req_err = client.get("not a url").build().unwrap_err();
        let http_err = BbmError::Http(req_err);
        // A builder/URL error is not retryable (no timeout/connect/5xx)
        assert!(!RetryPolicy::is_retryable(&http_err));
    }

    #[test]
    fn backoff_durations() {
        // attempt 0: 200ms * 2^(0-1) but saturating_sub gives 0, so 2^0 = 1 => 200ms
        let p0 = RetryPolicy {
            max_attempts: 3,
            current_attempt: 0,
        };
        assert_eq!(p0.backoff_duration(), Duration::from_millis(200));

        // attempt 1: 200ms * 2^0 = 200ms
        let p1 = RetryPolicy {
            max_attempts: 3,
            current_attempt: 1,
        };
        assert_eq!(p1.backoff_duration(), Duration::from_millis(200));

        // attempt 2: 200ms * 2^1 = 400ms
        let p2 = RetryPolicy {
            max_attempts: 3,
            current_attempt: 2,
        };
        assert_eq!(p2.backoff_duration(), Duration::from_millis(400));

        // attempt 3: 200ms * 2^2 = 800ms
        let p3 = RetryPolicy {
            max_attempts: 3,
            current_attempt: 3,
        };
        assert_eq!(p3.backoff_duration(), Duration::from_millis(800));
    }
}