use crate::error::YfError;
#[derive(Debug)]
pub enum Decision {
Success,
Retry {
backoff: std::time::Duration,
reset_auth: bool,
},
GiveUp { error: YfError },
}
pub fn backoff_for(attempt: u32) -> std::time::Duration {
std::time::Duration::from_secs(2u64.saturating_pow(attempt))
}
pub fn decide_status(status: u16, attempt: u32, retries: u32, retry_401: bool) -> Decision {
if (200..=299).contains(&status) {
return Decision::Success;
}
if status == 429 {
return Decision::GiveUp {
error: YfError::RateLimited,
};
}
if status == 401 {
if retry_401 && attempt < retries {
return Decision::Retry {
backoff: backoff_for(attempt + 1),
reset_auth: true,
};
}
return Decision::GiveUp {
error: YfError::msg("unauthorized (401) after retries"),
};
}
if (500..600).contains(&status) {
if attempt < retries {
return Decision::Retry {
backoff: backoff_for(attempt + 1),
reset_auth: false,
};
}
return Decision::GiveUp {
error: YfError::Status {
status,
body: String::new(),
},
};
}
Decision::GiveUp {
error: YfError::Status {
status,
body: String::new(),
},
}
}
pub fn decide_transport<E>(err: E, attempt: u32, retries: u32) -> Decision
where
E: Into<YfError>,
{
if attempt < retries {
Decision::Retry {
backoff: backoff_for(attempt + 1),
reset_auth: false,
}
} else {
Decision::GiveUp { error: err.into() }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn is_success(status: u16, attempt: u32, retries: u32, retry_401: bool) -> bool {
matches!(
decide_status(status, attempt, retries, retry_401),
Decision::Success
)
}
#[test]
fn success_on_2xx() {
assert!(is_success(200, 0, 3, true));
assert!(is_success(204, 2, 3, true));
assert!(is_success(299, 0, 0, false));
assert!(!is_success(199, 0, 3, true));
assert!(!is_success(300, 0, 3, true));
}
#[test]
fn rate_limited_never_retries() {
assert!(matches!(
decide_status(429, 0, 5, true),
Decision::GiveUp {
error: YfError::RateLimited
}
));
assert!(matches!(
decide_status(429, 0, 5, false),
Decision::GiveUp {
error: YfError::RateLimited
}
));
}
#[test]
fn unauthorized_json_retries_then_gives_up() {
let d = decide_status(401, 0, 3, true);
assert!(matches!(
d,
Decision::Retry {
backoff: _,
reset_auth: true
}
));
if let Decision::Retry { backoff, .. } = d {
assert_eq!(backoff, backoff_for(1));
}
let d = decide_status(401, 3, 3, true);
assert!(matches!(
d,
Decision::GiveUp {
error: YfError::Msg(_)
}
));
}
#[test]
fn unauthorized_text_never_retries() {
assert!(matches!(
decide_status(401, 0, 5, false),
Decision::GiveUp {
error: YfError::Msg(_)
}
));
}
#[test]
fn server_error_retries_within_budget() {
let d = decide_status(503, 1, 3, false);
assert!(matches!(
d,
Decision::Retry {
backoff: _,
reset_auth: false
}
));
if let Decision::Retry { backoff, .. } = d {
assert_eq!(backoff, backoff_for(2));
}
let d = decide_status(500, 3, 3, false);
assert!(matches!(
d,
Decision::GiveUp {
error: YfError::Status { status: 500, body }
} if body.is_empty()
));
}
#[test]
fn client_error_gives_up() {
assert!(matches!(
decide_status(403, 0, 5, true),
Decision::GiveUp {
error: YfError::Status { status: 403, body }
} if body.is_empty()
));
assert!(matches!(
decide_status(404, 2, 3, false),
Decision::GiveUp {
error: YfError::Status { status: 404, body }
} if body.is_empty()
));
}
#[test]
fn transport_retries_then_gives_up() {
let d = decide_transport(YfError::msg("boom"), 0, 3);
assert!(matches!(
d,
Decision::Retry {
reset_auth: false,
..
}
));
let d = decide_transport(YfError::msg("boom"), 3, 3);
assert!(matches!(d, Decision::GiveUp { .. }));
}
#[test]
fn backoff_grows_exponentially() {
assert_eq!(backoff_for(0), std::time::Duration::from_secs(1));
assert_eq!(backoff_for(1), std::time::Duration::from_secs(2));
assert_eq!(backoff_for(2), std::time::Duration::from_secs(4));
assert_eq!(backoff_for(3), std::time::Duration::from_secs(8));
}
#[test]
fn backoff_saturates() {
assert_eq!(
backoff_for(u32::MAX),
std::time::Duration::from_secs(u64::MAX)
);
}
}