use std::fmt;
use std::time::{Duration, SystemTime};
use reqwest::StatusCode;
use reqwest::header::{HeaderMap, RETRY_AFTER};
use crate::error::{RateLimitError, RateLimitKind};
const DEFAULT_MAX_RETRIES: u32 = 4;
const DEFAULT_MIN_WAIT: Duration = Duration::from_secs(1);
const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy)]
pub(crate) enum AttemptOutcome<'a> {
Response(StatusCode),
Error(&'a reqwest::Error),
}
impl AttemptOutcome<'_> {
fn status(&self) -> Option<StatusCode> {
match self {
Self::Response(status) => Some(*status),
Self::Error(_) => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct BackoffContext<'a> {
pub attempt: u32,
pub min_wait: Duration,
pub max_wait: Duration,
pub status: Option<StatusCode>,
pub headers: Option<&'a HeaderMap>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RateLimitDecision {
GiveUp,
Retry,
RetryAfter(Duration),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RateLimitRule {
pub max_retries: u32,
pub max_wait: Option<Duration>,
}
impl RateLimitRule {
pub const fn give_up() -> Self {
Self {
max_retries: 0,
max_wait: None,
}
}
pub const fn retries(n: u32) -> Self {
Self {
max_retries: n,
max_wait: None,
}
}
#[must_use]
pub const fn max_wait(mut self, wait: Duration) -> Self {
self.max_wait = Some(wait);
self
}
fn decide(&self, rl: &RateLimitError) -> RateLimitDecision {
if rl.retries >= self.max_retries {
return RateLimitDecision::GiveUp;
}
match (rl.retry_after, self.max_wait) {
(Some(wait), Some(max)) if wait > max => RateLimitDecision::GiveUp,
(Some(wait), _) if !wait.is_zero() => RateLimitDecision::RetryAfter(wait),
_ => RateLimitDecision::Retry,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum RateLimitPolicy {
#[default]
Default,
Rules {
primary: RateLimitRule,
secondary: RateLimitRule,
},
}
impl RateLimitPolicy {
pub fn rules(primary: RateLimitRule, secondary: RateLimitRule) -> Self {
Self::Rules { primary, secondary }
}
pub(crate) fn decide(&self, rl: &RateLimitError) -> RateLimitDecision {
match self {
Self::Default => {
if rl.status == StatusCode::TOO_MANY_REQUESTS {
RateLimitDecision::Retry
} else {
RateLimitDecision::GiveUp
}
}
Self::Rules { primary, secondary } => match rl.kind {
RateLimitKind::Primary => primary.decide(rl),
RateLimitKind::Secondary => secondary.decide(rl),
},
}
}
}
#[derive(Clone)]
pub struct RetryPolicy {
pub(crate) max_retries: u32,
pub(crate) min_wait: Duration,
pub(crate) max_wait: Duration,
pub(crate) max_elapsed: Option<Duration>,
pub(crate) rate_limit: RateLimitPolicy,
pub(crate) should_retry: Option<fn(&AttemptOutcome<'_>) -> bool>,
pub(crate) attempt_timeout: Option<Duration>,
pub(crate) also_retry: &'static [StatusCode],
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: DEFAULT_MAX_RETRIES,
min_wait: DEFAULT_MIN_WAIT,
max_wait: DEFAULT_MAX_WAIT,
max_elapsed: None,
rate_limit: RateLimitPolicy::Default,
should_retry: None,
attempt_timeout: None,
also_retry: &[],
}
}
}
impl fmt::Debug for RetryPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RetryPolicy")
.field("max_retries", &self.max_retries)
.field("min_wait", &self.min_wait)
.field("max_wait", &self.max_wait)
.field("max_elapsed", &self.max_elapsed)
.field("rate_limit", &self.rate_limit)
.finish_non_exhaustive()
}
}
impl RetryPolicy {
pub fn none() -> Self {
Self::default().max_retries(0)
}
#[must_use]
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = n;
self
}
#[must_use]
pub fn min_wait(mut self, wait: Duration) -> Self {
self.min_wait = wait;
self
}
#[must_use]
pub fn max_wait(mut self, wait: Duration) -> Self {
self.max_wait = wait;
self
}
#[must_use]
pub fn max_elapsed(mut self, budget: Duration) -> Self {
self.max_elapsed = Some(budget);
self
}
#[must_use]
pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
self.rate_limit = policy;
self
}
pub(crate) fn default_should_retry(outcome: &AttemptOutcome<'_>) -> bool {
match outcome {
AttemptOutcome::Error(e) => !is_certificate_error(e),
AttemptOutcome::Response(status) => {
*status == StatusCode::TOO_MANY_REQUESTS
|| (status.is_server_error() && *status != StatusCode::NOT_IMPLEMENTED)
}
}
}
pub(crate) fn default_backoff(ctx: &BackoffContext<'_>) -> Duration {
if let (Some(status), Some(headers)) = (ctx.status, ctx.headers)
&& (status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::SERVICE_UNAVAILABLE)
&& let Some(wait) = retry_after(headers, SystemTime::now())
{
return wait;
}
let exp = 2f64.powi(i32::try_from(ctx.attempt).unwrap_or(i32::MAX));
let backoff = ctx.min_wait.as_secs_f64() * exp;
if !backoff.is_finite() || backoff >= ctx.max_wait.as_secs_f64() {
return ctx.max_wait;
}
let wait = Duration::from_secs_f64(backoff);
let spread = wait.as_nanos() / 2;
let jitter = if spread > 0 {
let spread = u64::try_from(spread).unwrap_or(u64::MAX);
Duration::from_nanos(fastrand::u64(0..=spread))
} else {
Duration::ZERO
};
(wait + jitter).min(ctx.max_wait)
}
pub(crate) fn wants_retry(&self, outcome: &AttemptOutcome<'_>) -> bool {
if let Some(status) = outcome.status()
&& self.also_retry.contains(&status)
{
return true;
}
match &self.should_retry {
Some(f) => f(outcome),
None => Self::default_should_retry(outcome),
}
}
pub(crate) fn rate_limit_decision(&self, rl: &RateLimitError) -> RateLimitDecision {
if matches!(self.rate_limit, RateLimitPolicy::Default)
&& self.also_retry.contains(&rl.status)
{
return RateLimitDecision::Retry;
}
self.rate_limit.decide(rl)
}
}
fn is_certificate_error(err: &reqwest::Error) -> bool {
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = source {
let msg = e.to_string().to_ascii_lowercase();
if msg.contains("certificate") || msg.contains("unknownissuer") {
return true;
}
source = e.source();
}
false
}
pub(crate) fn retry_after(headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
parse_retry_after(value, now)
}
pub(crate) fn parse_retry_after(value: &str, now: SystemTime) -> Option<Duration> {
if value.is_empty() {
return None;
}
if let Ok(seconds) = value.parse::<i64>() {
return u64::try_from(seconds).ok().map(Duration::from_secs);
}
let when = httpdate::parse_http_date(value).ok()?;
when.duration_since(now).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
#[test]
fn retry_after_forms() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
assert_eq!(parse_retry_after("5", now), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after("0", now), Some(Duration::ZERO));
assert_eq!(parse_retry_after("-1", now), None);
assert_eq!(parse_retry_after("", now), None);
assert_eq!(parse_retry_after("soon", now), None);
let later = httpdate::fmt_http_date(now + Duration::from_secs(30));
assert_eq!(
parse_retry_after(&later, now),
Some(Duration::from_secs(30))
);
let earlier = httpdate::fmt_http_date(now - Duration::from_secs(30));
assert_eq!(parse_retry_after(&earlier, now), None);
}
#[test]
fn default_should_retry_statuses() {
let check = |code: u16| {
RetryPolicy::default_should_retry(&AttemptOutcome::Response(
StatusCode::from_u16(code).unwrap(),
))
};
assert!(check(429));
assert!(check(500));
assert!(check(502));
assert!(check(503));
assert!(!check(501));
assert!(!check(400));
assert!(!check(401));
assert!(!check(403));
assert!(!check(404));
}
#[test]
fn default_backoff_grows_and_caps() {
let ctx = |attempt| BackoffContext {
attempt,
min_wait: Duration::from_secs(1),
max_wait: Duration::from_secs(30),
status: None,
headers: None,
};
for attempt in 0..4 {
let base = Duration::from_secs(1 << attempt);
let wait = RetryPolicy::default_backoff(&ctx(attempt));
assert!(
wait >= base && wait <= base + base / 2,
"{attempt}: {wait:?}"
);
}
assert_eq!(
RetryPolicy::default_backoff(&ctx(10)),
Duration::from_secs(30)
);
assert_eq!(
RetryPolicy::default_backoff(&ctx(u32::MAX)),
Duration::from_secs(30)
);
}
#[test]
fn default_backoff_honors_retry_after() {
let mut headers = HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("120"));
let wait = RetryPolicy::default_backoff(&BackoffContext {
attempt: 0,
min_wait: Duration::from_secs(1),
max_wait: Duration::from_secs(30),
status: Some(StatusCode::TOO_MANY_REQUESTS),
headers: Some(&headers),
});
assert_eq!(wait, Duration::from_secs(120));
}
fn rate_limit(kind: RateLimitKind, retries: u32, after: Option<u64>) -> RateLimitError {
RateLimitError {
kind,
status: StatusCode::FORBIDDEN,
retry_after: after.map(Duration::from_secs),
reset: None,
retries,
response: crate::error::ApiError {
method: reqwest::Method::GET,
url: "u".into(),
status: StatusCode::FORBIDDEN,
kind: crate::ApiErrorKind::Other,
activity_id: None,
github_request_id: None,
exception: None,
message: String::new(),
},
}
}
#[test]
fn rules() {
let policy = RateLimitPolicy::rules(
RateLimitRule::give_up(),
RateLimitRule::retries(2).max_wait(Duration::from_secs(5)),
);
use RateLimitDecision::*;
use RateLimitKind::*;
assert_eq!(policy.decide(&rate_limit(Primary, 0, Some(1))), GiveUp);
assert_eq!(
policy.decide(&rate_limit(Secondary, 0, Some(3))),
RetryAfter(Duration::from_secs(3))
);
assert_eq!(policy.decide(&rate_limit(Secondary, 1, None)), Retry);
assert_eq!(policy.decide(&rate_limit(Secondary, 2, Some(1))), GiveUp);
assert_eq!(policy.decide(&rate_limit(Secondary, 0, Some(6))), GiveUp);
}
#[test]
fn default_rate_limit_policy() {
let mut rl = rate_limit(RateLimitKind::Secondary, 0, None);
assert_eq!(
RateLimitPolicy::Default.decide(&rl),
RateLimitDecision::GiveUp
);
rl.status = StatusCode::TOO_MANY_REQUESTS;
assert_eq!(
RateLimitPolicy::Default.decide(&rl),
RateLimitDecision::Retry
);
}
#[test]
fn also_retry_overrides_default_only() {
let policy = RetryPolicy {
also_retry: &[StatusCode::FORBIDDEN],
..RetryPolicy::default()
};
let rl = rate_limit(RateLimitKind::Secondary, 0, None);
assert_eq!(policy.rate_limit_decision(&rl), RateLimitDecision::Retry);
let mut policy = policy.rate_limit(RateLimitPolicy::rules(
RateLimitRule::give_up(),
RateLimitRule::give_up(),
));
policy.also_retry = &[StatusCode::FORBIDDEN];
assert_eq!(policy.rate_limit_decision(&rl), RateLimitDecision::GiveUp);
}
}