use std::time::Duration;
use super::error::BrainHttpError;
#[derive(Clone, Debug)]
pub struct HttpRetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for HttpRetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
}
}
}
impl HttpRetryPolicy {
#[must_use]
pub fn none() -> Self {
Self {
max_attempts: 1,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
}
}
#[must_use]
pub fn new(max_attempts: u32, base_delay: Duration, max_delay: Duration) -> Self {
Self {
max_attempts: max_attempts.max(1),
base_delay,
max_delay,
}
}
#[must_use]
pub(crate) fn is_retryable_status(status: u16) -> bool {
status == 0 || status == 429 || status == 503
}
#[must_use]
pub(crate) fn should_retry(&self, attempt: u32, err: &BrainHttpError) -> bool {
attempt < self.max_attempts && Self::is_retryable_status(err.status)
}
#[must_use]
pub(crate) fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
if let Some(ra) = retry_after {
return ra.min(self.max_delay);
}
if self.base_delay.is_zero() {
return Duration::ZERO;
}
let shift = attempt.saturating_sub(1).min(31);
let mult = 1u32 << shift;
self.base_delay
.checked_mul(mult)
.unwrap_or(self.max_delay)
.min(self.max_delay)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byok_missing_credential_status_is_not_retryable() {
assert!(
HttpRetryPolicy::is_retryable_status(429),
"429 carries Retry-After and an idempotent call is safe to repeat"
);
assert!(
!HttpRetryPolicy::is_retryable_status(400),
"retrying a malformed request cannot change the answer"
);
assert!(
!HttpRetryPolicy::is_retryable_status(409),
"llm_credential.missing (409) is terminal — retrying cannot create a key"
);
assert!(
HttpRetryPolicy::is_retryable_status(503),
"llm_credential.unavailable (503) is transient — it must retry"
);
}
#[test]
fn transport_503_and_429_are_retryable_and_nothing_else_is() {
assert!(HttpRetryPolicy::is_retryable_status(0));
assert!(HttpRetryPolicy::is_retryable_status(503));
assert!(HttpRetryPolicy::is_retryable_status(429));
for s in [400, 401, 404, 409, 500, 502, 504] {
assert!(
!HttpRetryPolicy::is_retryable_status(s),
"{s} must not retry"
);
}
}
#[test]
fn exponential_backoff_doubles_and_caps() {
let p = HttpRetryPolicy::new(4, Duration::from_millis(100), Duration::from_secs(2));
assert_eq!(p.backoff(1, None), Duration::from_millis(100));
assert_eq!(p.backoff(2, None), Duration::from_millis(200));
assert_eq!(p.backoff(3, None), Duration::from_millis(400));
assert_eq!(p.backoff(31, None), Duration::from_secs(2));
}
#[test]
fn retry_after_overrides_and_is_capped() {
let p = HttpRetryPolicy::default();
assert_eq!(
p.backoff(1, Some(Duration::from_millis(500))),
Duration::from_millis(500)
);
assert_eq!(
p.backoff(1, Some(Duration::from_secs(99))),
Duration::from_secs(2),
"server hint is capped at max_delay"
);
}
#[test]
fn should_retry_respects_attempts_and_status() {
let p = HttpRetryPolicy::default(); let transient = BrainHttpError {
status: 503,
code: "unavailable".into(),
message: String::new(),
};
let client_err = BrainHttpError {
status: 401,
code: "unauthorized".into(),
message: String::new(),
};
assert!(p.should_retry(1, &transient));
assert!(p.should_retry(2, &transient));
assert!(!p.should_retry(3, &transient), "exhausted at max_attempts");
assert!(!p.should_retry(1, &client_err), "4xx never retries");
}
}