use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use async_stream::stream;
use futures_util::StreamExt;
use tokio_util::sync::CancellationToken;
use super::contract::{
ChatChunk, ChatRequest, ChatStream, EngineBackend, FinishReason, VisionSupport,
};
use super::error::EngineError;
pub const MAX_ATTEMPTS: u32 = 3;
pub const BASE_DELAY: Duration = Duration::from_secs(1);
pub const BACKOFF_FACTOR: u32 = 2;
pub const JITTER: f64 = 0.25;
pub const RETRY_AFTER_CAP: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub factor: u32,
pub jitter: f64,
pub retry_after_cap: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: MAX_ATTEMPTS,
base_delay: BASE_DELAY,
factor: BACKOFF_FACTOR,
jitter: JITTER,
retry_after_cap: RETRY_AFTER_CAP,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
Wait(Duration),
GiveUp,
}
impl RetryPolicy {
fn decide(self, attempt: u32, transient: bool, retry_after: Option<Duration>) -> Decision {
if !transient || attempt >= self.max_attempts {
return Decision::GiveUp;
}
match retry_after {
Some(asked) if asked > self.retry_after_cap => Decision::GiveUp,
Some(asked) => Decision::Wait(asked),
None => Decision::Wait(self.backoff(attempt)),
}
}
fn backoff(self, attempt: u32) -> Duration {
let steps = attempt.saturating_sub(1);
let scale = self.factor.saturating_pow(steps);
let base = self.base_delay.saturating_mul(scale.max(1));
base.mul_f64(1.0 - self.jitter * unit_random())
}
}
fn unit_random() -> f64 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
f64::from(nanos % 1_000_000) / 1_000_000.0
}
enum Failure {
Pre {
error: anyhow::Error,
transient: bool,
retry_after: Option<Duration>,
},
InStream { message: String, transient: bool },
}
impl Failure {
fn transient(&self) -> bool {
match self {
Failure::Pre { transient, .. } | Failure::InStream { transient, .. } => *transient,
}
}
fn retry_after(&self) -> Option<Duration> {
match self {
Failure::Pre { retry_after, .. } => *retry_after,
Failure::InStream { .. } => None,
}
}
fn into_chunks(self) -> [ChatChunk; 2] {
match self {
Failure::Pre {
error, transient, ..
} => ChatChunk::failure(error.to_string(), transient),
Failure::InStream { message, transient } => ChatChunk::failure(message, transient),
}
}
}
enum Attempt {
Started {
head: Vec<ChatChunk>,
rest: ChatStream,
},
Failed {
head: Vec<ChatChunk>,
failure: Failure,
},
}
async fn run_attempt(
inner: &Arc<dyn EngineBackend>,
req: ChatRequest,
cancel: &CancellationToken,
) -> Attempt {
let mut stream = match inner.chat_stream(req, cancel.clone()).await {
Ok(stream) => stream,
Err(error) => {
let (transient, retry_after) = match error.downcast_ref::<EngineError>() {
Some(typed) => (typed.is_transient(), typed.retry_after),
None => (false, None),
};
return Attempt::Failed {
head: Vec::new(),
failure: Failure::Pre {
error,
transient,
retry_after,
},
};
}
};
let mut head = Vec::new();
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Error { message, transient } => {
return Attempt::Failed {
head,
failure: Failure::InStream { message, transient },
};
}
chunk @ (ChatChunk::Text(_)
| ChatChunk::Thoughts(_)
| ChatChunk::ThoughtsSignature(_)
| ChatChunk::ToolCall(_)
| ChatChunk::Finished(_)) => {
head.push(chunk);
return Attempt::Started { head, rest: stream };
}
chunk @ (ChatChunk::Usage(_) | ChatChunk::Retry { .. }) => head.push(chunk),
}
}
Attempt::Started { head, rest: stream }
}
fn resume(head: Vec<ChatChunk>, rest: ChatStream) -> ChatStream {
Box::pin(futures_util::stream::iter(head).chain(rest))
}
pub struct RetryBackend {
inner: Arc<dyn EngineBackend>,
policy: RetryPolicy,
}
impl RetryBackend {
pub fn wrap(inner: Arc<dyn EngineBackend>) -> Arc<dyn EngineBackend> {
Arc::new(Self {
inner,
policy: RetryPolicy::default(),
})
}
#[cfg(test)]
pub fn with_policy(inner: Arc<dyn EngineBackend>, policy: RetryPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait::async_trait]
impl EngineBackend for RetryBackend {
async fn chat_stream(&self, req: ChatRequest, cancel: CancellationToken) -> Result<ChatStream> {
let (head, failure) = match run_attempt(&self.inner, req.clone(), &cancel).await {
Attempt::Started { head, rest } => return Ok(resume(head, rest)),
Attempt::Failed { head, failure } => (head, failure),
};
let mut delay = match self
.policy
.decide(1, failure.transient(), failure.retry_after())
{
Decision::Wait(delay) => delay,
Decision::GiveUp => {
return match failure {
Failure::Pre { error, .. } => Err(error),
in_stream => Ok(resume(
[head, in_stream.into_chunks().to_vec()].concat(),
Box::pin(futures_util::stream::empty()),
)),
};
}
};
let inner = self.inner.clone();
let policy = self.policy;
Ok(Box::pin(stream! {
let mut attempt = 1u32;
loop {
attempt += 1;
yield ChatChunk::Retry { attempt, max: policy.max_attempts, delay };
tokio::select! {
biased;
_ = cancel.cancelled() => {
yield ChatChunk::Finished(FinishReason::Cancelled);
return;
}
_ = tokio::time::sleep(delay) => {}
}
match run_attempt(&inner, req.clone(), &cancel).await {
Attempt::Started { head, rest } => {
for chunk in head { yield chunk; }
let mut rest = rest;
while let Some(chunk) = rest.next().await { yield chunk; }
return;
}
Attempt::Failed { head, failure } => {
match policy.decide(attempt, failure.transient(), failure.retry_after()) {
Decision::Wait(next) => delay = next,
Decision::GiveUp => {
for chunk in head { yield chunk; }
for chunk in failure.into_chunks() { yield chunk; }
return;
}
}
}
}
}
}))
}
async fn context_budget(&self) -> Option<u32> {
self.inner.context_budget().await
}
async fn model_capabilities(&self) -> Option<crate::shared::api::contract::ModelCapabilities> {
self.inner.model_capabilities().await
}
async fn vision(&self) -> VisionSupport {
self.inner.vision().await
}
async fn model_id(&self) -> Option<String> {
self.inner.model_id().await
}
async fn parallel_slots(&self) -> Option<u32> {
self.inner.parallel_slots().await
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::shared::api::contract::{TokenUsage, ToolCallDelta};
use crate::shared::api::error::EngineErrorKind;
enum Outcome {
Pre(EngineError),
Chunks(Vec<ChatChunk>),
}
struct Scripted {
outcomes: Mutex<VecDeque<Outcome>>,
calls: AtomicUsize,
budget: Option<u32>,
caps: Option<crate::shared::api::contract::ModelCapabilities>,
vision: VisionSupport,
model: Option<String>,
slots: Option<u32>,
}
impl Scripted {
fn new(outcomes: Vec<Outcome>) -> Arc<Self> {
Arc::new(Self {
outcomes: Mutex::new(outcomes.into()),
calls: AtomicUsize::new(0),
budget: None,
caps: None,
vision: VisionSupport::Unknown,
model: None,
slots: None,
})
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl EngineBackend for Scripted {
async fn model_capabilities(
&self,
) -> Option<crate::shared::api::contract::ModelCapabilities> {
self.caps.clone()
}
async fn chat_stream(
&self,
_req: ChatRequest,
_cancel: CancellationToken,
) -> Result<ChatStream> {
self.calls.fetch_add(1, Ordering::SeqCst);
let outcome = self
.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Outcome::Pre(err(503, None)));
match outcome {
Outcome::Pre(e) => Err(e.into()),
Outcome::Chunks(chunks) => Ok(Box::pin(futures_util::stream::iter(chunks))),
}
}
async fn context_budget(&self) -> Option<u32> {
self.budget
}
async fn vision(&self) -> VisionSupport {
self.vision
}
async fn model_id(&self) -> Option<String> {
self.model.clone()
}
async fn parallel_slots(&self) -> Option<u32> {
self.slots
}
}
fn err(status: u16, retry_after: Option<Duration>) -> EngineError {
EngineError {
kind: EngineErrorKind::Status,
status: Some(status),
retry_after,
message: format!("engine returned status {status}"),
}
}
fn req() -> ChatRequest {
ChatRequest {
continue_final: false,
system: None,
messages: Vec::new(),
sampling: Default::default(),
tools: Vec::new(),
}
}
fn text() -> Vec<ChatChunk> {
vec![
ChatChunk::Text("hello".into()),
ChatChunk::Finished(FinishReason::Stop),
]
}
async fn drain(backend: &dyn EngineBackend) -> Result<Vec<ChatChunk>> {
let mut stream = backend.chat_stream(req(), CancellationToken::new()).await?;
let mut out = Vec::new();
while let Some(c) = stream.next().await {
out.push(c);
}
Ok(out)
}
fn retries(chunks: &[ChatChunk]) -> Vec<(u32, Duration)> {
chunks
.iter()
.filter_map(|c| match c {
ChatChunk::Retry { attempt, delay, .. } => Some((*attempt, *delay)),
_ => None,
})
.collect()
}
fn has_error(chunks: &[ChatChunk]) -> bool {
chunks.iter().any(|c| matches!(c, ChatChunk::Error { .. }))
}
#[tokio::test(start_paused = true)]
async fn a_transient_failure_is_retried_and_the_next_attempt_wins() {
let inner = Scripted::new(vec![Outcome::Pre(err(429, None)), Outcome::Chunks(text())]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(inner.calls(), 2, "the request must be re-issued once");
assert_eq!(retries(&chunks).len(), 1, "the wait must be announced");
assert_eq!(
retries(&chunks)[0].0,
2,
"the chip names the attempt about to start"
);
assert!(chunks.contains(&ChatChunk::Text("hello".into())));
assert!(
!has_error(&chunks),
"a recovered turn reports no error: {chunks:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_permanent_failure_is_not_retried_and_stays_an_err() {
let inner = Scripted::new(vec![Outcome::Pre(err(400, None))]);
let backend = RetryBackend::wrap(inner.clone());
let result = backend.chat_stream(req(), CancellationToken::new()).await;
let e = match result {
Ok(_) => panic!("a 400 must not be turned into a stream"),
Err(e) => e,
};
assert_eq!(inner.calls(), 1, "a 400 must be sent exactly once");
assert!(e.to_string().contains("400"), "{e}");
assert!(
e.downcast_ref::<EngineError>().is_some(),
"the typed error must pass through unchanged"
);
}
#[tokio::test(start_paused = true)]
async fn an_in_stream_failure_before_content_is_retried() {
let inner = Scripted::new(vec![
Outcome::Chunks(vec![
ChatChunk::Usage(TokenUsage::default()),
ChatChunk::Error {
message: "overloaded_error".into(),
transient: true,
},
ChatChunk::Finished(FinishReason::Error),
]),
Outcome::Chunks(text()),
]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(inner.calls(), 2);
assert!(chunks.contains(&ChatChunk::Text("hello".into())));
assert!(
!has_error(&chunks),
"the failed attempt's error must not reach the user: {chunks:?}"
);
}
#[tokio::test(start_paused = true)]
async fn text_commits_the_turn_so_a_later_failure_is_surfaced_not_retried() {
let inner = Scripted::new(vec![Outcome::Chunks(vec![
ChatChunk::Text("half an ans".into()),
ChatChunk::Error {
message: "overloaded_error".into(),
transient: true,
},
ChatChunk::Finished(FinishReason::Error),
])]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(
inner.calls(),
1,
"a rendered answer must never be re-requested"
);
assert!(has_error(&chunks));
assert_eq!(
chunks.last(),
Some(&ChatChunk::Finished(FinishReason::Error))
);
}
#[tokio::test(start_paused = true)]
async fn a_tool_call_commits_the_turn() {
let inner = Scripted::new(vec![Outcome::Chunks(vec![
ChatChunk::ToolCall(ToolCallDelta {
index: 0,
id: Some("c1".into()),
name: Some("fs_write".into()),
arguments: "{}".into(),
thought_signature: None,
}),
ChatChunk::Error {
message: "overloaded_error".into(),
transient: true,
},
ChatChunk::Finished(FinishReason::Error),
])]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(
inner.calls(),
1,
"replaying a round that emitted a tool call would re-run the tool"
);
assert!(chunks.iter().any(|c| matches!(c, ChatChunk::ToolCall(_))));
}
#[tokio::test(start_paused = true)]
async fn the_attempt_budget_is_spent_then_the_failure_is_reported() {
let inner = Scripted::new(vec![
Outcome::Pre(err(503, None)),
Outcome::Pre(err(503, None)),
Outcome::Pre(err(503, None)),
Outcome::Chunks(text()), ]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(inner.calls(), MAX_ATTEMPTS as usize, "exactly the budget");
assert_eq!(retries(&chunks).len(), 2, "two waits for three attempts");
let reported = chunks.iter().find_map(|c| match c {
ChatChunk::Error { message, .. } => Some(message.clone()),
_ => None,
});
assert!(
reported.is_some_and(|m| m.contains("503")),
"the last failure's own text is what gets reported: {chunks:?}"
);
assert_eq!(
chunks.last(),
Some(&ChatChunk::Finished(FinishReason::Error))
);
}
#[tokio::test(start_paused = true)]
async fn a_retry_after_within_the_cap_is_honoured_exactly() {
let asked = Duration::from_secs(5);
let inner = Scripted::new(vec![
Outcome::Pre(err(429, Some(asked))),
Outcome::Chunks(text()),
]);
let backend = RetryBackend::wrap(inner.clone());
let chunks = drain(backend.as_ref()).await.unwrap();
assert_eq!(inner.calls(), 2);
assert_eq!(retries(&chunks)[0].1, asked);
}
#[tokio::test(start_paused = true)]
async fn a_retry_after_beyond_the_cap_fails_fast_instead_of_waiting() {
let inner = Scripted::new(vec![Outcome::Pre(err(
429,
Some(RETRY_AFTER_CAP + Duration::from_secs(1)),
))]);
let backend = RetryBackend::wrap(inner.clone());
let result = backend.chat_stream(req(), CancellationToken::new()).await;
assert!(result.is_err(), "a quota-length wait is not hidden");
assert_eq!(inner.calls(), 1, "and nothing is retried");
}
#[tokio::test(start_paused = true)]
async fn cancelling_during_the_backoff_ends_the_turn_at_once() {
let inner = Scripted::new(vec![
Outcome::Pre(err(503, None)),
Outcome::Chunks(text()), ]);
let backend = RetryBackend::wrap(inner.clone());
let cancel = CancellationToken::new();
let mut stream = backend.chat_stream(req(), cancel.clone()).await.unwrap();
let first = stream.next().await;
assert!(matches!(first, Some(ChatChunk::Retry { .. })), "{first:?}");
cancel.cancel();
assert_eq!(
stream.next().await,
Some(ChatChunk::Finished(FinishReason::Cancelled))
);
assert_eq!(inner.calls(), 1, "the second attempt must not be made");
}
#[tokio::test]
async fn the_context_budget_is_delegated() {
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().budget = Some(16384);
let backend = RetryBackend::wrap(scripted);
assert_eq!(backend.context_budget().await, Some(16384));
}
#[tokio::test]
async fn the_model_capabilities_are_delegated() {
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().caps =
Some(crate::shared::api::contract::ModelCapabilities {
context_length: Some(64000),
sampling_fields: Some(vec!["temperature".to_string()].into()),
});
let backend = RetryBackend::wrap(scripted);
let caps = backend
.model_capabilities()
.await
.expect("the inner backend answered");
assert_eq!(caps.context_length, Some(64000));
assert_eq!(
caps.sampling_fields.as_deref(),
Some(["temperature".to_string()].as_slice())
);
}
#[tokio::test]
async fn vision_is_delegated() {
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().vision = VisionSupport::Supported;
let backend = RetryBackend::wrap(scripted);
assert_eq!(backend.vision().await, VisionSupport::Supported);
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().vision = VisionSupport::Unsupported;
let backend = RetryBackend::wrap(scripted);
assert_eq!(backend.vision().await, VisionSupport::Unsupported);
}
#[tokio::test]
async fn the_model_name_is_delegated() {
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().model = Some("gemma-4-31B_q4_0-it".into());
let backend = RetryBackend::wrap(scripted);
assert_eq!(
backend.model_id().await.as_deref(),
Some("gemma-4-31B_q4_0-it")
);
}
#[tokio::test]
async fn the_slot_count_is_delegated() {
let mut scripted = Scripted::new(vec![]);
Arc::get_mut(&mut scripted).unwrap().slots = Some(4);
let backend = RetryBackend::wrap(scripted);
assert_eq!(backend.parallel_slots().await, Some(4));
}
#[tokio::test(start_paused = true)]
async fn a_single_attempt_policy_never_retries() {
let inner = Scripted::new(vec![Outcome::Pre(err(503, None))]);
let policy = RetryPolicy {
max_attempts: 1,
..RetryPolicy::default()
};
let backend = RetryBackend::with_policy(inner.clone(), policy);
let result = backend.chat_stream(req(), CancellationToken::new()).await;
assert!(result.is_err());
assert_eq!(inner.calls(), 1, "a budget of one means one request");
}
#[test]
fn the_policy_decides_by_transience_then_budget_then_the_header() {
let p = RetryPolicy::default();
assert_eq!(p.decide(1, false, None), Decision::GiveUp);
assert!(matches!(p.decide(1, true, None), Decision::Wait(_)));
assert!(matches!(p.decide(2, true, None), Decision::Wait(_)));
assert_eq!(p.decide(MAX_ATTEMPTS, true, None), Decision::GiveUp);
assert_eq!(
p.decide(1, true, Some(Duration::from_secs(7))),
Decision::Wait(Duration::from_secs(7))
);
assert_eq!(
p.decide(1, true, Some(RETRY_AFTER_CAP + Duration::from_millis(1))),
Decision::GiveUp
);
assert_eq!(
p.decide(1, true, Some(RETRY_AFTER_CAP)),
Decision::Wait(RETRY_AFTER_CAP)
);
}
#[test]
fn the_backoff_grows_and_jitter_only_shortens_it() {
let p = RetryPolicy::default();
for _ in 0..64 {
let first = p.backoff(1);
let second = p.backoff(2);
assert!(
first <= BASE_DELAY && first >= BASE_DELAY.mul_f64(1.0 - JITTER),
"{first:?}"
);
let doubled = BASE_DELAY * BACKOFF_FACTOR;
assert!(
second <= doubled && second >= doubled.mul_f64(1.0 - JITTER),
"{second:?}"
);
assert!(
second > first,
"the wait must grow: {first:?} -> {second:?}"
);
}
}
}