use std::time::Duration;
use reqwest::header::HeaderMap;
use super::ClientOptions;
use crate::error::Error;
#[derive(Debug)]
pub(crate) enum Step {
Done,
Backoff(Duration),
Fail(Error),
}
pub(crate) fn is_retryable_status(status: u16) -> bool {
matches!(status, 408 | 500 | 502 | 503 | 504)
}
#[cfg(not(feature = "capture-v1"))]
pub(crate) fn should_retry_v0(status: u16, has_retry_after: bool) -> bool {
is_retryable_status(status) || (status == 429 && has_retry_after)
}
pub(crate) fn parse_retry_after(headers: &HeaderMap) -> Option<u64> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
}
pub(crate) fn backoff_duration(
opts: &ClientOptions,
attempt: u32,
retry_after_secs: Option<u64>,
) -> Duration {
if let Some(secs) = retry_after_secs {
Duration::from_secs(secs)
} else {
let base_ms = opts.retry_initial_backoff_ms;
let max_ms = opts.retry_max_backoff_ms;
let backoff_ms = base_ms.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
Duration::from_millis(backoff_ms.min(max_ms))
}
}
#[cfg(not(feature = "capture-v1"))]
pub(crate) fn v0_after_response(
opts: &ClientOptions,
attempt: u32,
status: u16,
retry_after: Option<u64>,
body: &str,
) -> Step {
if should_retry_v0(status, retry_after.is_some()) && attempt < opts.max_capture_attempts {
return Step::Backoff(backoff_duration(opts, attempt, retry_after));
}
match Error::from_http_response(status, body.to_string()) {
Some(err) => Step::Fail(err),
None => Step::Done,
}
}
#[cfg(not(feature = "capture-v1"))]
pub(crate) fn v0_after_transport_error(
opts: &ClientOptions,
attempt: u32,
err_msg: String,
) -> Step {
if attempt >= opts.max_capture_attempts {
return Step::Fail(Error::Connection(err_msg));
}
Step::Backoff(backoff_duration(opts, attempt, None))
}
#[cfg(test)]
mod tests {
use reqwest::header::{HeaderMap, HeaderValue};
use super::*;
use crate::client::ClientOptionsBuilder;
fn test_opts() -> ClientOptions {
ClientOptionsBuilder::default()
.api_key("phc_test".to_string())
.max_capture_attempts(3u32)
.retry_initial_backoff_ms(100u64)
.retry_max_backoff_ms(5000u64)
.build()
.unwrap()
}
#[test]
fn retryable_statuses() {
for code in [408, 500, 502, 503, 504] {
assert!(
is_retryable_status(code),
"expected {} to be retryable",
code
);
}
}
#[test]
fn non_retryable_statuses() {
for code in [200, 201, 400, 401, 402, 403, 413, 415, 418, 429, 404] {
assert!(
!is_retryable_status(code),
"expected {} to NOT be retryable",
code
);
}
}
#[cfg(not(feature = "capture-v1"))]
#[test]
fn v0_retries_429_only_with_retry_after() {
assert!(should_retry_v0(429, true));
assert!(!should_retry_v0(429, false));
for code in [408, 500, 502, 503, 504] {
assert!(should_retry_v0(code, false));
assert!(should_retry_v0(code, true));
}
for code in [400, 401, 402, 403, 413, 415] {
assert!(!should_retry_v0(code, false));
assert!(!should_retry_v0(code, true));
}
}
#[test]
fn parse_retry_after_cases() {
let cases: [(Option<&str>, Option<u64>); 3] = [
(Some("5"), Some(5)),
(None, None),
(Some("not-a-number"), None),
];
for (header_val, expected) in cases {
let mut headers = HeaderMap::new();
if let Some(v) = header_val {
headers.insert("retry-after", HeaderValue::from_str(v).unwrap());
}
assert_eq!(
parse_retry_after(&headers),
expected,
"header={:?}",
header_val
);
}
}
#[test]
fn backoff_explicit_retry_after_wins() {
let opts = test_opts();
assert_eq!(
backoff_duration(&opts, 1, Some(42)),
Duration::from_secs(42)
);
}
#[test]
fn backoff_exponential_growth() {
let opts = test_opts();
assert_eq!(backoff_duration(&opts, 1, None), Duration::from_millis(100));
assert_eq!(backoff_duration(&opts, 2, None), Duration::from_millis(200));
assert_eq!(backoff_duration(&opts, 3, None), Duration::from_millis(400));
}
#[test]
fn backoff_clamped_to_max() {
let opts = ClientOptionsBuilder::default()
.api_key("k".to_string())
.retry_initial_backoff_ms(100u64)
.retry_max_backoff_ms(150u64)
.build()
.unwrap();
assert_eq!(backoff_duration(&opts, 3, None), Duration::from_millis(150));
}
#[cfg(not(feature = "capture-v1"))]
fn schedule_opts() -> ClientOptions {
ClientOptionsBuilder::default()
.api_key("phc_test".to_string())
.max_capture_attempts(10u32)
.retry_initial_backoff_ms(100u64)
.retry_max_backoff_ms(1_000_000u64)
.build()
.unwrap()
}
#[cfg(not(feature = "capture-v1"))]
fn backoff_ms(step: Step) -> u64 {
match step {
Step::Backoff(d) => d.as_millis() as u64,
_ => panic!("expected Step::Backoff"),
}
}
#[cfg(not(feature = "capture-v1"))]
#[test]
fn v0_backoff_schedule_starts_at_initial() {
let opts = schedule_opts();
assert_eq!(
backoff_ms(v0_after_response(&opts, 1, 503, None, "")),
100,
"first retry must honor retry_initial_backoff_ms exactly"
);
assert_eq!(backoff_ms(v0_after_response(&opts, 2, 503, None, "")), 200);
assert_eq!(backoff_ms(v0_after_response(&opts, 3, 503, None, "")), 400);
assert_eq!(
backoff_ms(v0_after_transport_error(&opts, 1, "timeout".into())),
100
);
assert_eq!(
backoff_ms(v0_after_transport_error(&opts, 2, "timeout".into())),
200
);
}
#[cfg(not(feature = "capture-v1"))]
#[test]
fn v0_after_response_terminal_and_success() {
let opts = schedule_opts();
assert!(matches!(
v0_after_response(&opts, 1, 200, None, ""),
Step::Done
));
assert!(matches!(
v0_after_response(&opts, 1, 429, None, ""),
Step::Fail(Error::RateLimit)
));
assert!(matches!(
v0_after_response(&opts, 1, 429, Some(1), ""),
Step::Backoff(_)
));
assert!(matches!(
v0_after_response(&opts, 1, 400, None, "bad"),
Step::Fail(Error::BadRequest(_))
));
}
#[cfg(not(feature = "capture-v1"))]
#[test]
fn v0_exhausts_attempt_budget() {
let opts = ClientOptionsBuilder::default()
.api_key("phc_test".to_string())
.max_capture_attempts(3u32)
.retry_initial_backoff_ms(1u64)
.retry_max_backoff_ms(5u64)
.build()
.unwrap();
assert!(matches!(
v0_after_response(&opts, 3, 503, None, "boom"),
Step::Fail(Error::ServerError { .. })
));
assert!(matches!(
v0_after_transport_error(&opts, 3, "timeout".into()),
Step::Fail(Error::Connection(_))
));
}
}