use std::time::Duration;
use crate::TastyTradeError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionState {
Connected,
Reconnecting {
attempt: u32,
delay: Duration,
},
Disconnected {
reason: String,
},
}
impl ConnectionState {
pub fn is_connected(&self) -> bool {
matches!(self, ConnectionState::Connected)
}
pub fn is_terminal(&self) -> bool {
matches!(self, ConnectionState::Disconnected { .. })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BackoffPolicy {
pub initial: Duration,
pub max_delay: Duration,
pub max_attempts: Option<u32>,
pub jitter: f64,
}
impl Default for BackoffPolicy {
fn default() -> Self {
Self {
initial: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
max_attempts: Some(8),
jitter: 0.25,
}
}
}
impl BackoffPolicy {
pub fn delay_for(&self, attempt: u32, now_nanos: u64) -> Option<Duration> {
if attempt == 0 {
return None;
}
if let Some(max) = self.max_attempts
&& attempt > max
{
return None;
}
let factor = 2u32.saturating_pow(attempt.saturating_sub(1));
let base = u64::try_from(
self.initial
.saturating_mul(factor)
.min(self.max_delay)
.as_nanos(),
)
.unwrap_or(u64::MAX);
if self.jitter <= 0.0 {
return Some(Duration::from_nanos(base));
}
let span = (base as f64 * self.jitter.clamp(0.0, 1.0)) as u64;
let offset = if span == 0 { 0 } else { now_nanos % span };
Some(Duration::from_nanos(base.saturating_sub(offset)))
}
pub fn should_retry(&self, error: &TastyTradeError) -> bool {
match error {
TastyTradeError::Auth(_)
| TastyTradeError::ConfigError(_)
| TastyTradeError::Precondition(_) => false,
other => other.is_retryable() || matches!(other, TastyTradeError::Streaming(_)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> BackoffPolicy {
BackoffPolicy {
initial: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
max_attempts: Some(5),
jitter: 0.0,
}
}
#[test]
fn the_delay_doubles_until_it_hits_the_ceiling() {
let p = policy();
assert_eq!(p.delay_for(1, 0), Some(Duration::from_millis(100)));
assert_eq!(p.delay_for(2, 0), Some(Duration::from_millis(200)));
assert_eq!(p.delay_for(3, 0), Some(Duration::from_millis(400)));
assert_eq!(p.delay_for(5, 0), Some(Duration::from_millis(1600)));
}
#[test]
fn the_policy_stops_at_its_attempt_limit() {
let p = policy();
assert!(p.delay_for(5, 0).is_some(), "the last attempt is allowed");
assert_eq!(p.delay_for(6, 0), None, "one past the limit is refused");
assert_eq!(p.delay_for(u32::MAX, 0), None);
}
#[test]
fn a_huge_attempt_count_saturates_rather_than_overflowing() {
let p = BackoffPolicy {
max_attempts: None,
..policy()
};
assert_eq!(p.delay_for(1_000, 0), Some(Duration::from_secs(2)));
assert_eq!(p.delay_for(u32::MAX, 0), Some(Duration::from_secs(2)));
}
#[test]
fn jitter_spreads_the_delay_downward_only() {
let p = BackoffPolicy {
jitter: 0.5,
..policy()
};
let base = Duration::from_millis(400);
for nanos in [0, 1, 12_345, 999_999_999, u64::MAX] {
let delay = p.delay_for(3, nanos).expect("within the limit");
assert!(
delay <= base,
"jitter must not push a delay above the ceiling: {delay:?}"
);
assert!(
delay >= base / 2,
"jitter must not collapse the delay to nothing: {delay:?}"
);
}
}
#[test]
fn an_enormous_delay_saturates_rather_than_wrapping() {
let p = BackoffPolicy {
initial: Duration::from_secs(u64::MAX / 1_000),
max_delay: Duration::MAX,
max_attempts: None,
jitter: 0.0,
};
let delay = p.delay_for(64, 0).expect("no attempt limit");
assert!(
delay >= Duration::from_secs(1),
"a huge delay must not wrap to a tiny one: {delay:?}"
);
}
#[test]
fn attempt_zero_is_not_a_retry() {
assert_eq!(policy().delay_for(0, 0), None);
}
#[test]
fn authentication_and_configuration_failures_are_not_retried() {
let p = policy();
assert!(!p.should_retry(&TastyTradeError::Auth("rejected".into())));
assert!(!p.should_retry(&TastyTradeError::ConfigError("missing".into())));
assert!(!p.should_retry(&TastyTradeError::Precondition("wrong account".into())));
}
#[test]
fn a_dropped_stream_is_retried() {
let p = policy();
assert!(p.should_retry(&TastyTradeError::Streaming("socket closed".into())));
assert!(p.should_retry(&TastyTradeError::Connection("refused".into())));
}
#[test]
fn the_state_reports_itself_without_naming_anything_private() {
let reconnecting = ConnectionState::Reconnecting {
attempt: 3,
delay: Duration::from_millis(400),
};
assert!(!reconnecting.is_connected());
assert!(!reconnecting.is_terminal());
let done = ConnectionState::Disconnected {
reason: "gave up after 8 attempts".to_string(),
};
assert!(done.is_terminal());
let rendered = format!("{reconnecting:?} {done:?}");
assert!(rendered.contains("attempt: 3"));
}
}