use std::fmt;
use std::time::{Duration, Instant};
#[cfg(test)]
use crate::util::UnwrapPoison;
use crate::{ChatRequest, ChatResponse};
pub(crate) const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 13;
pub(crate) const DEFAULT_RETRY_BASE_BACKOFF_MS: u64 = 5_000;
pub(crate) const DEFAULT_RETRY_MAX_BACKOFF_MS: u64 = 60_000;
pub(crate) const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_mins(12);
pub(crate) const DEFAULT_SYNTHESIS_MAX_ATTEMPTS: u32 = 3;
pub(crate) const DEFAULT_SYNTHESIS_BASE_BACKOFF_MS: u64 = 30_000;
pub(crate) const DEFAULT_SYNTHESIS_MAX_BACKOFF_MS: u64 = 45_000;
pub(crate) const DEFAULT_CONTINUATION_MAX_ATTEMPTS: u32 = 3;
pub(crate) const DEFAULT_CONTINUATION_TIMEOUT: Duration = Duration::from_secs(90);
pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_mins(1);
const RETRY_AFTER_MIN_MS: u64 = 5_000;
pub(crate) const RETRY_AFTER_MAX_MS: u64 = 60_000;
#[derive(Debug, Clone)]
pub(crate) struct RetryPolicy {
pub max_attempts: u32,
pub base_backoff_ms: u64,
pub max_backoff_ms: u64,
pub operation_timeout: Duration,
pub idle_timeout: Duration,
}
impl RetryPolicy {
#[must_use]
pub(crate) fn default() -> Self {
Self {
max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
base_backoff_ms: DEFAULT_RETRY_BASE_BACKOFF_MS,
max_backoff_ms: DEFAULT_RETRY_MAX_BACKOFF_MS,
operation_timeout: DEFAULT_OPERATION_TIMEOUT,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
}
}
#[must_use]
pub(crate) fn current() -> Self {
#[cfg(test)]
if let Some(p) = test_override() {
return p;
}
Self::default()
}
#[must_use]
pub(crate) fn synthesis() -> Self {
#[cfg(test)]
if let Some(p) = test_override() {
return p;
}
Self {
max_attempts: DEFAULT_SYNTHESIS_MAX_ATTEMPTS,
base_backoff_ms: DEFAULT_SYNTHESIS_BASE_BACKOFF_MS,
max_backoff_ms: DEFAULT_SYNTHESIS_MAX_BACKOFF_MS,
operation_timeout: Duration::from_mins(10),
idle_timeout: DEFAULT_IDLE_TIMEOUT,
}
}
#[must_use]
pub(crate) fn comment() -> Self {
#[cfg(test)]
if let Some(p) = test_override() {
return p;
}
Self {
max_attempts: 3,
base_backoff_ms: DEFAULT_RETRY_BASE_BACKOFF_MS,
max_backoff_ms: DEFAULT_RETRY_MAX_BACKOFF_MS,
operation_timeout: Duration::from_secs(90),
idle_timeout: DEFAULT_IDLE_TIMEOUT,
}
}
#[must_use]
pub(crate) fn continuation() -> Self {
#[cfg(test)]
if let Some(p) = test_override() {
return p;
}
Self {
max_attempts: DEFAULT_CONTINUATION_MAX_ATTEMPTS,
base_backoff_ms: 0,
max_backoff_ms: 0,
operation_timeout: DEFAULT_CONTINUATION_TIMEOUT,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
}
}
}
#[cfg(test)]
static TEST_POLICY_OVERRIDE: std::sync::RwLock<Option<RetryPolicy>> = std::sync::RwLock::new(None);
#[cfg(test)]
fn test_override() -> Option<RetryPolicy> {
let guard = TEST_POLICY_OVERRIDE.read().unwrap_poison();
guard.as_ref().cloned()
}
#[cfg(test)]
pub(crate) fn swap_test_retry_policy(policy: RetryPolicy) -> Option<RetryPolicy> {
let mut guard = TEST_POLICY_OVERRIDE.write().unwrap_poison();
let previous = guard.take();
*guard = Some(policy);
previous
}
#[cfg(test)]
pub(crate) fn restore_test_retry_policy(previous: Option<RetryPolicy>) {
*TEST_POLICY_OVERRIDE.write().unwrap_poison() = previous;
}
#[cfg(test)]
pub(crate) fn tiny_test_policy() -> RetryPolicy {
RetryPolicy {
max_attempts: 3,
base_backoff_ms: 1,
max_backoff_ms: 1,
operation_timeout: Duration::from_mins(1),
idle_timeout: Duration::from_secs(1),
}
}
#[must_use]
pub(crate) fn backoff_sequence(policy: &RetryPolicy) -> Vec<u64> {
let sleeps = policy.max_attempts.saturating_sub(1) as usize;
let mut seq = Vec::with_capacity(sleeps);
for i in 0..sleeps {
let doubled = policy.base_backoff_ms.saturating_mul(1u64 << i.min(6));
seq.push(doubled.min(policy.max_backoff_ms));
}
seq
}
#[must_use]
pub(crate) fn jittered_backoff_ms(base_ms: u64) -> u64 {
base_ms - base_ms / 4 + (rand::random::<u64>() % (base_ms / 2).max(1))
}
#[must_use]
pub(crate) fn compute_sleep_ms(schedule_ms: u64, retry_after_ms: Option<u64>) -> u64 {
if let Some(ra) = retry_after_ms {
ra.clamp(RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS)
} else {
jittered_backoff_ms(schedule_ms)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FailureClass {
Transport,
TruncatedEnvelope,
Parse,
OutOfRangeScore,
Membership,
Completeness,
ContradictionAgents,
ValidationOther,
TruncatedOutput,
NoResponse,
NonRetryable,
Shutdown,
WallClockExceeded,
}
impl FailureClass {
#[must_use]
pub(crate) const fn is_retryable(self) -> bool {
!matches!(
self,
Self::NonRetryable | Self::Shutdown | Self::WallClockExceeded
)
}
#[must_use]
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Transport => "transport",
Self::TruncatedEnvelope => "truncated_envelope",
Self::Parse => "parse",
Self::OutOfRangeScore => "out_of_range_score",
Self::Membership => "membership",
Self::Completeness => "completeness",
Self::ContradictionAgents => "contradiction_agents",
Self::ValidationOther => "validation_other",
Self::TruncatedOutput => "truncated_output",
Self::NoResponse => "no_response",
Self::NonRetryable => "non_retryable",
Self::Shutdown => "shutdown",
Self::WallClockExceeded => "wall_clock_exceeded",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RetryFailureRecord {
pub class: FailureClass,
pub error_chain: String,
pub finish_reason: Option<String>,
pub retry_after_ms: Option<u64>,
}
impl RetryFailureRecord {
#[must_use]
pub(crate) fn new_simple(
class: FailureClass,
error: &anyhow::Error,
retry_after_ms: Option<u64>,
) -> Self {
Self {
class,
error_chain: format!("{error:#}"),
finish_reason: None,
retry_after_ms,
}
}
#[must_use]
pub(crate) fn with_metadata(
class: FailureClass,
error: &anyhow::Error,
finish_reason: Option<String>,
retry_after_ms: Option<u64>,
) -> Self {
Self {
finish_reason,
..Self::new_simple(class, error, retry_after_ms)
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RetryExhausted {
pub failures: Vec<RetryFailureRecord>,
pub final_class: FailureClass,
pub last_raw: Option<String>,
pub detail: String,
}
impl RetryExhausted {
#[must_use]
fn with_trail(
failures: Vec<RetryFailureRecord>,
final_class: FailureClass,
last_raw: Option<String>,
) -> Self {
let detail = if let Some(last) = failures.last() {
format!(
"{} attempt(s) failed (last: {}): {}",
failures.len(),
final_class.label(),
last.error_chain,
)
} else {
format!("operation failed: {}", final_class.label())
};
Self {
failures,
final_class,
last_raw,
detail,
}
}
#[must_use]
fn new(failures: Vec<RetryFailureRecord>, final_class: FailureClass) -> Self {
Self::with_trail(failures, final_class, None)
}
#[must_use]
pub(crate) fn with_last_raw(
failures: Vec<RetryFailureRecord>,
final_class: FailureClass,
last_raw: Option<String>,
) -> Self {
Self::with_trail(failures, final_class, last_raw)
}
#[must_use]
pub(crate) fn shutdown(failures: Vec<RetryFailureRecord>) -> Self {
Self::new(failures, FailureClass::Shutdown)
}
#[must_use]
pub(crate) fn wall_clock(failures: Vec<RetryFailureRecord>) -> Self {
Self::new(failures, FailureClass::WallClockExceeded)
}
}
impl fmt::Display for RetryExhausted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.detail)
}
}
impl std::error::Error for RetryExhausted {}
pub(crate) async fn fail_exhausted<T>(
request: &ChatRequest,
operation_started: Instant,
exhausted: RetryExhausted,
) -> Result<T, RetryExhausted> {
crate::stats::record_llm_failure(request, operation_started, &exhausted).await;
Err(exhausted)
}
pub(crate) struct RetryLoop {
policy: RetryPolicy,
deadline: Instant,
backoffs: Vec<u64>,
failures: Vec<RetryFailureRecord>,
last_retry_after: Option<u64>,
}
impl RetryLoop {
#[must_use]
pub(crate) fn new(policy: &RetryPolicy) -> Self {
Self {
policy: policy.clone(),
deadline: Instant::now() + policy.operation_timeout,
backoffs: backoff_sequence(policy),
failures: Vec::new(),
last_retry_after: None,
}
}
#[must_use]
pub(crate) fn deadline(&self) -> Instant {
self.deadline
}
#[must_use]
pub(crate) fn expired(&self) -> bool {
Instant::now() >= self.deadline
}
#[must_use]
pub(crate) fn into_failures(self) -> Vec<RetryFailureRecord> {
self.failures
}
#[must_use]
pub(crate) fn has_failures(&self) -> bool {
!self.failures.is_empty()
}
pub(crate) fn record(&mut self, rec: RetryFailureRecord) {
self.last_retry_after = rec.retry_after_ms;
self.failures.push(rec);
}
#[expect(clippy::cast_possible_truncation)]
pub(crate) async fn sleep_between(&self, attempt: u32) -> Result<(), FailureClass> {
if attempt >= self.policy.max_attempts {
return Ok(());
}
let remaining = self.deadline.saturating_duration_since(Instant::now());
let schedule_ms = self.backoffs[(attempt - 1) as usize];
let remaining_ms = remaining.as_millis() as u64 + u64::from(remaining.subsec_nanos() > 0);
let sleep_ms = compute_sleep_ms(schedule_ms, self.last_retry_after).min(remaining_ms);
if !crate::shutdown::sleep_or_shutdown(Duration::from_millis(sleep_ms)).await {
return Err(FailureClass::Shutdown);
}
Ok(())
}
#[must_use]
pub(crate) fn final_class(&self) -> FailureClass {
self.failures
.last()
.map_or(FailureClass::Transport, |r| r.class)
}
}
pub(crate) async fn agent_chat(
request: ChatRequest,
policy: &RetryPolicy,
) -> Result<ChatResponse, RetryExhausted> {
let mut loop_state = RetryLoop::new(policy);
let operation_started = Instant::now();
for attempt in 1..=policy.max_attempts {
if loop_state.expired() {
let exhausted = RetryExhausted::wall_clock(loop_state.into_failures());
return fail_exhausted(&request, operation_started, exhausted).await;
}
match crate::providers::chat_scoped(
request.clone(),
policy.idle_timeout,
loop_state.deadline(),
)
.await
{
Ok(resp) => {
crate::stats::record_llm_success(&request, operation_started, attempt, &resp).await;
return Ok(resp);
}
Err(err) => {
let non_retryable = !err.class.is_retryable();
loop_state.record(err.record);
if non_retryable {
let exhausted = RetryExhausted::new(loop_state.into_failures(), err.class);
return fail_exhausted(&request, operation_started, exhausted).await;
}
}
}
if let Err(FailureClass::Shutdown) = loop_state.sleep_between(attempt).await {
let exhausted = RetryExhausted::shutdown(loop_state.into_failures());
return fail_exhausted(&request, operation_started, exhausted).await;
}
}
let final_class = loop_state.final_class();
let exhausted = RetryExhausted::new(loop_state.into_failures(), final_class);
fail_exhausted(&request, operation_started, exhausted).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_backoff_sequence_is_doubling_capped() {
let p = RetryPolicy {
max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
base_backoff_ms: DEFAULT_RETRY_BASE_BACKOFF_MS,
max_backoff_ms: DEFAULT_RETRY_MAX_BACKOFF_MS,
operation_timeout: DEFAULT_OPERATION_TIMEOUT,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
};
assert_eq!(
backoff_sequence(&p),
vec![
5_000, 10_000, 20_000, 40_000, 60_000, 60_000, 60_000, 60_000, 60_000, 60_000,
60_000, 60_000
]
);
}
#[test]
fn custom_backoff_sequence_doubles_capped() {
let p = RetryPolicy {
max_attempts: 6,
base_backoff_ms: 10_000,
max_backoff_ms: 30_000,
operation_timeout: DEFAULT_OPERATION_TIMEOUT,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
};
assert_eq!(
backoff_sequence(&p),
vec![10_000, 20_000, 30_000, 30_000, 30_000]
);
}
#[test]
fn compute_sleep_honors_retry_after_clamped() {
assert_eq!(compute_sleep_ms(5_000, Some(7_000)), 7_000);
assert_eq!(compute_sleep_ms(5_000, Some(1_000)), 5_000);
assert_eq!(compute_sleep_ms(5_000, Some(200_000)), 60_000);
}
#[test]
fn compute_sleep_jitter_within_25_percent() {
for _ in 0..200 {
let v = compute_sleep_ms(10_000, None);
assert!(
(7_500..12_500).contains(&v),
"jitter out of ±25% for base 10000: {v}"
);
}
}
#[test]
fn stale_retry_after_does_not_stick_across_failures() {
let policy = tiny_test_policy();
let mut loop_state = RetryLoop::new(&policy);
let rec = RetryFailureRecord::new_simple(
FailureClass::Transport,
&anyhow::anyhow!("429 rate limited"),
Some(60_000),
);
loop_state.record(rec);
assert_eq!(loop_state.last_retry_after, Some(60_000));
let rec = RetryFailureRecord::new_simple(
FailureClass::NoResponse,
&anyhow::anyhow!("empty response"),
None,
);
loop_state.record(rec);
assert_eq!(
loop_state.last_retry_after, None,
"stale Retry-After must not stick to later sleeps"
);
}
#[test]
fn failure_class_labels_and_retryability() {
assert!(FailureClass::Transport.is_retryable());
assert!(FailureClass::TruncatedEnvelope.is_retryable());
assert!(FailureClass::Parse.is_retryable());
assert!(FailureClass::OutOfRangeScore.is_retryable());
assert!(!FailureClass::NonRetryable.is_retryable());
assert!(!FailureClass::Shutdown.is_retryable());
assert!(!FailureClass::WallClockExceeded.is_retryable());
assert_eq!(
FailureClass::TruncatedEnvelope.label(),
"truncated_envelope"
);
}
#[test]
fn defaults_are_hardcoded() {
let _guard = crate::util::test::retry_tests_lock();
let policy = RetryPolicy::default();
assert_eq!(policy.max_attempts, DEFAULT_RETRY_MAX_ATTEMPTS);
assert_eq!(policy.base_backoff_ms, DEFAULT_RETRY_BASE_BACKOFF_MS);
assert_eq!(policy.max_backoff_ms, DEFAULT_RETRY_MAX_BACKOFF_MS);
assert_eq!(policy.operation_timeout, DEFAULT_OPERATION_TIMEOUT);
let synthesis = RetryPolicy::synthesis();
assert_eq!(synthesis.max_attempts, DEFAULT_SYNTHESIS_MAX_ATTEMPTS);
assert_eq!(synthesis.base_backoff_ms, DEFAULT_SYNTHESIS_BASE_BACKOFF_MS);
assert_eq!(synthesis.max_backoff_ms, DEFAULT_SYNTHESIS_MAX_BACKOFF_MS);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn agent_chat_rides_out_sustained_outage_and_recovers() {
let _guard = crate::util::test::retry_tests_lock();
let policy = RetryPolicy {
max_attempts: 13,
base_backoff_ms: 1,
max_backoff_ms: 1,
operation_timeout: Duration::from_mins(12),
idle_timeout: Duration::from_secs(1),
};
let mut fake = crate::util::test::FakeProvider::new();
for i in 0..12 {
fake = fake.err(FailureClass::Transport, &format!("503 outage attempt {i}"));
}
let fake = fake.ok("recovered");
let _provider_guard = crate::util::test::install_fake_provider(std::sync::Arc::new(fake));
let request = crate::providers::test_request(vec![crate::ChatMessage::user("hi")], None);
let resp = agent_chat(request, &policy)
.await
.expect("must recover on attempt 13");
assert_eq!(resp.text_or_empty(), "recovered");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn agent_chat_wall_clock_cap_binds() {
let _guard = crate::util::test::retry_tests_lock();
let policy = RetryPolicy {
max_attempts: 13,
base_backoff_ms: 1_000,
max_backoff_ms: 1_000,
operation_timeout: Duration::from_millis(100),
idle_timeout: Duration::from_secs(1),
};
let fake = crate::util::test::FakeProvider::new()
.err(FailureClass::Transport, "slow outage")
.err(FailureClass::Transport, "slow outage");
let _provider_guard = crate::util::test::install_fake_provider(std::sync::Arc::new(fake));
let request = crate::providers::test_request(vec![crate::ChatMessage::user("hi")], None);
let failure = agent_chat(request, &policy)
.await
.expect_err("wall-clock cap must bind");
assert_eq!(failure.final_class, FailureClass::WallClockExceeded);
assert!(
failure.failures.len() <= 2,
"cap must stop the loop before 13 attempts"
);
}
}