use std::time::Duration;
use polyoxide_core::reqwest::StatusCode;
use polyoxide_core::{AttemptInfo, Decision, Outcome, ResponseMeta, RetryConfig, RetryPolicy};
use crate::usdm::{
error::retry_after_secs,
weight::{WeightBudget, DEFAULT_BAN},
};
#[derive(Debug, Clone)]
pub(crate) struct UsdmRetryPolicy {
pub(crate) budget: WeightBudget,
}
impl RetryPolicy for UsdmRetryPolicy {
fn decide(
&self,
response: &ResponseMeta<'_>,
attempt: &AttemptInfo,
schedule: &RetryConfig,
) -> Decision {
let asked = retry_after_secs(response.retry_after());
match response.status {
StatusCode::TOO_MANY_REQUESTS if attempt.retries_left > 0 => Decision {
outcome: Outcome::Retry(Duration::ZERO),
hold: Some(
schedule
.retry_delay(attempt.attempt, response.retry_after())
.max(asked.unwrap_or_default()),
),
},
StatusCode::TOO_MANY_REQUESTS => Decision {
outcome: Outcome::Fail,
hold: Some(asked.unwrap_or_else(|| self.budget.until_next_minute())),
},
StatusCode::IM_A_TEAPOT => Decision {
outcome: Outcome::Fail,
hold: Some(asked.unwrap_or(DEFAULT_BAN)),
},
status if status.is_success() => Decision {
outcome: Outcome::Done,
hold: None,
},
_ => Decision {
outcome: Outcome::Fail,
hold: None,
},
}
}
}
#[cfg(test)]
mod tests {
use polyoxide_core::reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
use super::*;
const FIFTEEN_INTO_A_MINUTE: u64 = 1_791_362_115_000;
fn policy() -> UsdmRetryPolicy {
UsdmRetryPolicy {
budget: WeightBudget::at_ms(FIFTEEN_INTO_A_MINUTE),
}
}
fn schedule() -> RetryConfig {
RetryConfig {
max_retries: 1,
initial_backoff_ms: 100,
max_backoff_ms: 10_000,
}
}
fn decide(status: u16, retry_after: Option<&str>, attempt: u32) -> Decision {
let mut headers = HeaderMap::new();
if let Some(value) = retry_after {
headers.insert(RETRY_AFTER, HeaderValue::from_str(value).unwrap());
}
let schedule = schedule();
policy().decide(
&ResponseMeta {
status: StatusCode::from_u16(status).unwrap(),
headers: &headers,
},
&schedule.attempt_info(attempt),
&schedule,
)
}
#[test]
fn a_2xx_is_done() {
for status in [200, 204] {
assert_eq!(
decide(status, Some("5"), 0),
Decision {
outcome: Outcome::Done,
hold: None
},
"{status}"
);
}
}
#[test]
fn a_429_with_a_retry_left_holds_the_longer_of_its_wait_and_retry_after() {
let asked = decide(429, Some("1"), 0);
assert_eq!(asked.outcome, Outcome::Retry(Duration::ZERO));
assert_eq!(asked.hold, Some(Duration::from_secs(1)));
let unasked = decide(429, None, 0);
assert_eq!(unasked.outcome, Outcome::Retry(Duration::ZERO));
let hold = unasked.hold.unwrap().as_millis();
assert!((75..=125).contains(&hold), "{hold}ms");
let banned = decide(429, Some("864000"), 0);
assert_eq!(banned.hold, Some(crate::usdm::weight::MAX_COOLDOWN));
}
#[test]
fn a_429_with_none_left_holds_its_retry_after() {
assert_eq!(
decide(429, Some("1"), 1),
Decision {
outcome: Outcome::Fail,
hold: Some(Duration::from_secs(1))
}
);
}
#[test]
fn a_429_with_none_left_and_no_retry_after_holds_to_the_next_minute() {
assert_eq!(
decide(429, None, 1),
Decision {
outcome: Outcome::Fail,
hold: Some(Duration::from_secs(45))
}
);
}
#[test]
fn a_418_fails_and_holds_its_retry_after() {
for attempt in [0, 1] {
assert_eq!(
decide(418, Some("1"), attempt),
Decision {
outcome: Outcome::Fail,
hold: Some(Duration::from_secs(1))
},
"attempt {attempt}"
);
}
assert_eq!(
decide(418, Some("864000"), 0).hold,
Some(crate::usdm::weight::MAX_COOLDOWN)
);
}
#[test]
fn a_418_without_retry_after_holds_two_minutes() {
assert_eq!(
decide(418, None, 0),
Decision {
outcome: Outcome::Fail,
hold: Some(Duration::from_secs(120))
}
);
}
#[test]
fn a_425_and_a_5xx_fail_with_no_hold() {
for status in [425, 500, 503, 400, 403, 451] {
assert_eq!(
decide(status, Some("1"), 0),
Decision {
outcome: Outcome::Fail,
hold: None
},
"{status}"
);
}
}
}