use std::sync::Arc;
use std::time::Duration;
use bevy_ecs::entity::Entity;
use leviath_providers::{InferenceRequest, InferenceResponse, Provider, ProviderError};
use tokio::sync::Notify;
use tokio::sync::mpsc::UnboundedSender;
use crate::inference_pool::InferencePermit;
pub const DEFAULT_RETRY_ATTEMPTS: u32 = 4;
pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 1_000;
pub const CAPACITY_BASE_DELAY_SECS: u64 = 15;
pub const CAPACITY_MAX_DELAY_SECS: u64 = 60;
pub const MAX_TOTAL_BACKOFF_SECS: u64 = 300;
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub capacity_base_delay: Duration,
pub capacity_max_delay: Duration,
pub max_total_backoff: Duration,
pub job_timeout: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: DEFAULT_RETRY_ATTEMPTS,
base_delay: Duration::from_millis(DEFAULT_RETRY_BASE_DELAY_MS),
capacity_base_delay: Duration::from_secs(CAPACITY_BASE_DELAY_SECS),
capacity_max_delay: Duration::from_secs(CAPACITY_MAX_DELAY_SECS),
max_total_backoff: Duration::from_secs(MAX_TOTAL_BACKOFF_SECS),
job_timeout: Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
}
}
}
pub struct InferenceJob {
pub entity: Entity,
pub provider: Arc<dyn Provider>,
pub request: InferenceRequest,
pub permit: InferencePermit,
pub exact_token_counting: bool,
}
fn flatten_request_text(request: &InferenceRequest) -> String {
let mut parts: Vec<String> = Vec::new();
for block in &request.system {
parts.push(block.text.clone());
}
for msg in &request.messages {
parts.push(msg.content.as_text());
}
for tool in &request.tools {
parts.push(tool.name.clone());
parts.push(tool.description.clone());
parts.push(tool.parameters.to_string());
}
parts.join("\n")
}
fn exponential(base: Duration, attempt: u32) -> Duration {
base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1).min(16)))
}
fn backoff_after(
policy: &RetryPolicy,
error: &ProviderError,
attempt: u32,
spent: Duration,
) -> Option<Duration> {
if !error.is_transient() || attempt >= policy.max_attempts {
return None;
}
let remaining = policy
.max_total_backoff
.checked_sub(spent)
.filter(|left| !left.is_zero())?;
let advice = error.retry_advice();
let delay = match (advice.capacity, advice.retry_after_secs) {
(true, Some(secs)) => Duration::from_secs(secs).min(policy.capacity_max_delay),
(true, None) => {
exponential(policy.capacity_base_delay, attempt).min(policy.capacity_max_delay)
}
(false, _) => exponential(policy.base_delay, attempt),
};
Some(delay.min(remaining))
}
pub struct InferenceOutcome {
pub entity: Entity,
pub result: Result<InferenceResponse, ProviderError>,
pub latency: std::time::Duration,
}
pub async fn run_inference_job(
job: InferenceJob,
results: UnboundedSender<InferenceOutcome>,
wake: Arc<Notify>,
retry: RetryPolicy,
cancel: crate::cancel::CancelToken,
) {
let InferenceJob {
entity,
provider,
request,
permit,
exact_token_counting,
} = job;
let started = std::time::Instant::now();
if exact_token_counting {
let text = flatten_request_text(&request);
let used = provider.count_tokens(&text, &request.model).await;
let max = provider.max_context_tokens(&request.model);
if used.saturating_add(request.max_tokens) > max {
drop(permit);
let _ = results.send(InferenceOutcome {
entity,
result: Err(ProviderError::TokenLimitExceeded { used, max }),
latency: started.elapsed(),
});
wake.notify_one();
return;
}
}
let attempts = async {
let mut attempt = 1u32;
let mut spent = Duration::ZERO;
loop {
match provider.infer(&request).await {
Ok(response) => break Ok(response),
Err(e) => match backoff_after(&retry, &e, attempt, spent) {
Some(delay) => {
tokio::time::sleep(delay).await;
spent = spent.saturating_add(delay);
attempt += 1;
}
None => break Err(e),
},
}
}
};
let result = tokio::select! {
biased;
_ = cancel.cancelled() => {
drop(permit);
return;
}
outcome = tokio::time::timeout(retry.job_timeout, attempts) => match outcome {
Ok(result) => result,
Err(_elapsed) => Err(leviath_providers::ProviderError::Other(format!(
"inference exceeded the {}s job timeout and was aborted to free the \
pool slot (a stalled or never-completing response)",
retry.job_timeout.as_secs()
))),
},
};
drop(permit); let _ = results.send(InferenceOutcome {
entity,
result,
latency: started.elapsed(),
});
wake.notify_one();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference_pool::{InferencePoolConfig, InferencePools};
use tokio::sync::mpsc;
fn test_request() -> InferenceRequest {
InferenceRequest {
system: vec![],
messages: vec![],
model: "m".to_string(),
max_tokens: 100,
temperature: 0.0,
tools: vec![],
extra: serde_json::Value::Null,
request_timeout_secs: None,
}
}
fn response(text: &str) -> InferenceResponse {
InferenceResponse {
content: text.to_string(),
tool_calls: vec![],
tokens_used: leviath_providers::TokenUsage {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
cached_tokens: 0,
cache_write_tokens: 0,
},
finish_reason: leviath_providers::FinishReason::Complete,
}
}
enum Fixed {
Ok(InferenceResponse),
Err(String),
}
#[async_trait::async_trait]
impl Provider for Fixed {
async fn infer(
&self,
_req: &InferenceRequest,
) -> leviath_providers::Result<InferenceResponse> {
match self {
Fixed::Ok(r) => Ok(r.clone()),
Fixed::Err(m) => Err(ProviderError::Other(m.clone())),
}
}
async fn count_tokens(&self, _text: &str, _model: &str) -> usize {
1
}
fn max_context_tokens(&self, _model: &str) -> usize {
100_000
}
fn name(&self) -> &str {
"fixed"
}
fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
leviath_providers::ModelCapabilities::default()
}
}
fn job(provider: Arc<dyn Provider>) -> InferenceJob {
let pools = InferencePools::new(InferencePoolConfig::new());
InferenceJob {
entity: Entity::from_raw_u32(7)
.expect("a small literal index is always a valid entity id"),
provider,
request: test_request(),
permit: pools.try_acquire("m").expect("free pool"),
exact_token_counting: false,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_cancelled_job_frees_its_pool_slot_without_reporting() {
let mut cfg = InferencePoolConfig::new();
cfg.set_limit("m", 1);
let pools = InferencePools::new(cfg);
let permit = pools.try_acquire("m").expect("free pool");
assert!(pools.try_acquire("m").is_none(), "pool should be full");
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(vec![Step::Hang].into()),
calls: std::sync::Mutex::new(0),
});
let job = InferenceJob {
entity: Entity::from_raw_u32(7)
.expect("a small literal index is always a valid entity id"),
provider,
request: test_request(),
permit,
exact_token_counting: false,
};
let (tx, mut rx) = mpsc::unbounded_channel();
let cancel = crate::cancel::CancelToken::new();
let running = tokio::spawn(run_inference_job(
job,
tx,
Arc::new(Notify::new()),
RetryPolicy {
max_attempts: 1,
job_timeout: Duration::from_secs(3600),
..instant()
},
cancel.clone(),
));
tokio::task::yield_now().await;
cancel.cancel();
tokio::time::timeout(Duration::from_secs(5), running)
.await
.expect("the cancel ended the job")
.unwrap();
assert!(
pools.try_acquire("m").is_some(),
"the pool slot is free for the next agent"
);
assert!(
rx.try_recv().is_err(),
"and no outcome is reported for a cancelled run"
);
}
#[tokio::test]
async fn run_job_aborts_a_hung_call_and_frees_the_pool_slot() {
let mut cfg = InferencePoolConfig::new();
cfg.set_limit("m", 1);
let pools = InferencePools::new(cfg);
let permit = pools.try_acquire("m").expect("free pool");
assert!(pools.try_acquire("m").is_none(), "pool should be full");
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(vec![Step::Hang].into()),
calls: std::sync::Mutex::new(0),
});
let job = InferenceJob {
entity: Entity::from_raw_u32(7)
.expect("a small literal index is always a valid entity id"),
provider,
request: test_request(),
permit,
exact_token_counting: false,
};
let (tx, mut rx) = mpsc::unbounded_channel();
let policy = RetryPolicy {
max_attempts: 1,
job_timeout: Duration::from_millis(50),
..instant()
};
run_inference_job(
job,
tx,
Arc::new(Notify::new()),
policy,
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
let err = outcome.result.expect_err("hung call should error");
assert!(err.to_string().contains("job timeout"), "got: {err}");
assert!(
pools.try_acquire("m").is_some(),
"the slot must be released after the timeout"
);
}
#[tokio::test]
async fn run_job_reports_ok_and_wakes() {
let (tx, mut rx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
run_inference_job(
job(Arc::new(Fixed::Ok(response("hi")))),
tx,
wake.clone(),
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert_eq!(
outcome.entity,
Entity::from_raw_u32(7).expect("a small literal index is always a valid entity id")
);
assert_eq!(outcome.result.unwrap().content, "hi");
wake.notified().await;
}
#[tokio::test]
async fn run_job_reports_provider_error() {
let (tx, mut rx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let err = Arc::new(Fixed::Err("boom".to_string()));
run_inference_job(
job(err),
tx,
wake,
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert!(outcome.result.is_err());
}
struct Counter {
count: usize,
max: usize,
}
#[async_trait::async_trait]
impl Provider for Counter {
async fn infer(
&self,
_req: &InferenceRequest,
) -> leviath_providers::Result<InferenceResponse> {
Ok(response("ok"))
}
async fn count_tokens(&self, _text: &str, _model: &str) -> usize {
self.count
}
fn max_context_tokens(&self, _model: &str) -> usize {
self.max
}
fn name(&self) -> &str {
"counter"
}
fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
leviath_providers::ModelCapabilities::default()
}
}
fn counting_job(provider: Arc<dyn Provider>, exact: bool) -> InferenceJob {
let pools = InferencePools::new(InferencePoolConfig::new());
InferenceJob {
entity: Entity::from_raw_u32(7)
.expect("a small literal index is always a valid entity id"),
provider,
request: test_request(), permit: pools.try_acquire("m").expect("free pool"),
exact_token_counting: exact,
}
}
#[test]
fn flatten_request_text_includes_system_messages_and_tools() {
use leviath_providers::{SystemBlock, Tool};
let req = InferenceRequest {
system: vec![SystemBlock {
text: "sys".to_string(),
cache_hint: leviath_core::CacheHint::Never,
}],
messages: vec![leviath_providers::Message {
role: "user".to_string(),
content: "hello".into(),
cache_breakpoint: false,
}],
model: "m".to_string(),
max_tokens: 10,
temperature: 0.0,
tools: vec![Tool {
name: "search".to_string(),
description: "find things".to_string(),
parameters: serde_json::json!({"type": "object"}),
}],
extra: serde_json::Value::Null,
request_timeout_secs: None,
};
let text = flatten_request_text(&req);
assert!(text.contains("sys"));
assert!(text.contains("hello"));
assert!(text.contains("search"));
assert!(text.contains("find things"));
assert!(text.contains("object"));
}
#[tokio::test]
async fn guard_rejects_request_over_context_window() {
let (tx, mut rx) = mpsc::unbounded_channel();
let provider = Arc::new(Counter {
count: 950,
max: 1000,
});
run_inference_job(
counting_job(provider, true),
tx,
Arc::new(Notify::new()),
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
let err = outcome.result.expect_err("should be rejected");
assert_eq!(err.to_string(), "Token limit exceeded: 950 > 1000");
}
#[tokio::test]
async fn guard_allows_request_within_context_window() {
let (tx, mut rx) = mpsc::unbounded_channel();
let provider = Arc::new(Counter {
count: 800,
max: 1000,
});
run_inference_job(
counting_job(provider, true),
tx,
Arc::new(Notify::new()),
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert_eq!(outcome.result.expect("should succeed").content, "ok");
}
#[tokio::test]
async fn counter_provider_metadata_is_exercised() {
let p = Counter { count: 5, max: 10 };
assert_eq!(p.name(), "counter");
assert_eq!(p.max_context_tokens("m"), 10);
assert_eq!(p.count_tokens("t", "m").await, 5);
assert!(p.capabilities("m").supports_streaming);
}
#[tokio::test]
async fn guard_off_skips_the_count_and_proceeds() {
let (tx, mut rx) = mpsc::unbounded_channel();
let provider = Arc::new(Counter {
count: 1_000_000,
max: 1000,
});
run_inference_job(
counting_job(provider, false),
tx,
Arc::new(Notify::new()),
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert_eq!(outcome.result.expect("should succeed").content, "ok");
}
#[tokio::test]
async fn fixed_provider_metadata_is_exercised() {
let p = Fixed::Ok(response("x"));
assert_eq!(p.name(), "fixed");
assert_eq!(p.count_tokens("t", "m").await, 1);
assert_eq!(p.max_context_tokens("m"), 100_000);
let _ = p.capabilities("m");
}
#[tokio::test]
async fn run_job_survives_dropped_receiver() {
let (tx, rx) = mpsc::unbounded_channel();
drop(rx); let wake = Arc::new(Notify::new());
run_inference_job(
job(Arc::new(Fixed::Ok(response("x")))),
tx,
wake,
RetryPolicy::default(),
crate::cancel::CancelToken::new(),
)
.await;
}
enum Step {
Ok(String),
Transient,
Overloaded,
Permanent,
Hang,
}
struct Scripted {
steps: std::sync::Mutex<std::collections::VecDeque<Step>>,
calls: std::sync::Mutex<u32>,
}
#[async_trait::async_trait]
impl Provider for Scripted {
async fn infer(
&self,
_req: &InferenceRequest,
) -> leviath_providers::Result<InferenceResponse> {
*self.calls.lock().unwrap() += 1;
let step = self.steps.lock().unwrap().pop_front();
match step {
Some(Step::Ok(t)) => Ok(response(&t)),
Some(Step::Transient) => Err(ProviderError::RateLimitExceeded {
retry_after_secs: None,
}),
Some(Step::Overloaded) => {
Err(ProviderError::ApiError("HTTP 529 Overloaded".to_string()))
}
Some(Step::Permanent) => Err(ProviderError::Other("permanent".to_string())),
Some(Step::Hang) => std::future::pending().await,
None => Err(ProviderError::Other("exhausted".to_string())),
}
}
async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1
}
fn max_context_tokens(&self, _m: &str) -> usize {
100_000
}
fn name(&self) -> &str {
"scripted"
}
fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
leviath_providers::ModelCapabilities::default()
}
}
fn instant() -> RetryPolicy {
RetryPolicy {
base_delay: Duration::ZERO,
capacity_base_delay: Duration::ZERO,
capacity_max_delay: Duration::ZERO,
..RetryPolicy::default()
}
}
fn no_delay(max_attempts: u32) -> RetryPolicy {
RetryPolicy {
max_attempts,
job_timeout: Duration::from_secs(30),
..instant()
}
}
#[tokio::test]
async fn run_job_retries_transient_then_succeeds() {
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(
vec![
Step::Transient,
Step::Transient,
Step::Ok("done".to_string()),
]
.into(),
),
calls: std::sync::Mutex::new(0),
});
let (tx, mut rx) = mpsc::unbounded_channel();
run_inference_job(
job(provider.clone()),
tx,
Arc::new(Notify::new()),
no_delay(4),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert_eq!(outcome.result.unwrap().content, "done");
assert_eq!(*provider.calls.lock().unwrap(), 3); }
#[tokio::test]
async fn run_job_gives_up_after_max_attempts() {
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(
vec![
Step::Transient,
Step::Transient,
Step::Transient,
Step::Transient,
]
.into(),
),
calls: std::sync::Mutex::new(0),
});
let (tx, mut rx) = mpsc::unbounded_channel();
run_inference_job(
job(provider.clone()),
tx,
Arc::new(Notify::new()),
no_delay(3),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert!(outcome.result.is_err());
assert_eq!(*provider.calls.lock().unwrap(), 3); }
#[tokio::test]
async fn run_job_does_not_retry_a_permanent_error() {
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(vec![Step::Permanent, Step::Ok("x".to_string())].into()),
calls: std::sync::Mutex::new(0),
});
let (tx, mut rx) = mpsc::unbounded_channel();
run_inference_job(
job(provider.clone()),
tx,
Arc::new(Notify::new()),
no_delay(4),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert!(outcome.result.is_err());
assert_eq!(*provider.calls.lock().unwrap(), 1); }
fn blip() -> ProviderError {
ProviderError::RequestFailed("connection reset by peer".to_string())
}
fn overloaded() -> ProviderError {
ProviderError::ApiError("HTTP 529 Overloaded".to_string())
}
#[test]
fn an_ordinary_blip_keeps_the_fast_schedule() {
let policy = RetryPolicy::default();
let spent = Duration::ZERO;
assert_eq!(
backoff_after(&policy, &blip(), 1, spent),
Some(Duration::from_secs(1))
);
assert_eq!(
backoff_after(&policy, &blip(), 2, spent),
Some(Duration::from_secs(2))
);
assert_eq!(
backoff_after(&policy, &blip(), 3, spent),
Some(Duration::from_secs(4))
);
assert_eq!(backoff_after(&policy, &blip(), 4, spent), None);
}
#[test]
fn an_overload_waits_long_enough_to_leave_the_window() {
let policy = RetryPolicy::default();
let spent = Duration::ZERO;
assert_eq!(
backoff_after(&policy, &overloaded(), 1, spent),
Some(Duration::from_secs(15))
);
assert_eq!(
backoff_after(&policy, &overloaded(), 2, spent),
Some(Duration::from_secs(30))
);
assert_eq!(
backoff_after(&policy, &overloaded(), 3, spent),
Some(Duration::from_secs(60))
);
assert_eq!(
backoff_after(
&policy,
&ProviderError::RateLimitExceeded {
retry_after_secs: None
},
1,
spent
),
Some(Duration::from_secs(15))
);
}
#[test]
fn the_servers_own_answer_wins_and_is_capped() {
let policy = RetryPolicy::default();
let hint = |secs| ProviderError::RateLimitExceeded {
retry_after_secs: Some(secs),
};
assert_eq!(
backoff_after(&policy, &hint(3), 1, Duration::ZERO),
Some(Duration::from_secs(3))
);
assert_eq!(
backoff_after(&policy, &hint(3600), 1, Duration::ZERO),
Some(Duration::from_secs(60))
);
}
#[test]
fn a_permanent_error_is_never_retried() {
assert_eq!(
backoff_after(
&RetryPolicy::default(),
&ProviderError::TokenLimitExceeded { used: 9, max: 8 },
1,
Duration::ZERO
),
None
);
}
#[test]
fn the_total_backoff_ceiling_bounds_however_long_a_provider_asks_for() {
let policy = RetryPolicy {
max_attempts: 100,
..RetryPolicy::default()
};
assert_eq!(
backoff_after(
&policy,
&overloaded(),
5,
policy.max_total_backoff - Duration::from_secs(2)
),
Some(Duration::from_secs(2))
);
assert_eq!(
backoff_after(&policy, &overloaded(), 5, policy.max_total_backoff),
None
);
assert_eq!(
backoff_after(
&policy,
&overloaded(),
5,
policy.max_total_backoff + Duration::from_secs(1)
),
None
);
}
#[test]
fn a_long_schedule_saturates_rather_than_overflowing() {
let policy = RetryPolicy {
max_attempts: u32::MAX,
base_delay: Duration::from_secs(u64::MAX / 2),
..RetryPolicy::default()
};
assert_eq!(
backoff_after(&policy, &blip(), u32::MAX - 1, Duration::ZERO),
Some(policy.max_total_backoff)
);
}
#[tokio::test]
async fn run_job_retries_an_overloaded_provider() {
let provider = Arc::new(Scripted {
steps: std::sync::Mutex::new(
vec![
Step::Overloaded,
Step::Overloaded,
Step::Ok("survived the overload".to_string()),
]
.into(),
),
calls: std::sync::Mutex::new(0),
});
let (tx, mut rx) = mpsc::unbounded_channel();
run_inference_job(
job(provider.clone()),
tx,
Arc::new(Notify::new()),
no_delay(4),
crate::cancel::CancelToken::new(),
)
.await;
let outcome = rx.try_recv().expect("outcome sent");
assert_eq!(outcome.result.unwrap().content, "survived the overload");
assert_eq!(*provider.calls.lock().unwrap(), 3);
}
#[tokio::test]
async fn scripted_provider_metadata_is_exercised() {
let p = Scripted {
steps: std::sync::Mutex::new(std::collections::VecDeque::new()),
calls: std::sync::Mutex::new(0),
};
assert_eq!(p.name(), "scripted");
assert_eq!(p.count_tokens("t", "m").await, 1);
assert_eq!(p.max_context_tokens("m"), 100_000);
let _ = p.capabilities("m");
}
}