Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 127 additions & 10 deletions aimux-core/src/decision_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,11 +559,6 @@ pub trait DecisionModel: Send + Sync {
snapshot.profile = Some(serde_json::json!({"decision_capabilities": self.capabilities()}));
snapshot
}
/// Retry settings for [`decide`] (#226). A per-call `max_retries`
/// overrides only the retry count.
fn retry_config(&self) -> retry::RetryConfig {
retry::RetryConfig::default()
}
/// Perform one provider attempt.
/// # Errors
/// Returns provider, transport or invalid response errors.
Expand All @@ -575,8 +570,18 @@ pub trait DecisionModel: Send + Sync {
/// Returns invalid request, unsupported capability, provider, invalid response,
/// retry exhaustion, timeout or caller abort errors.
pub async fn decide(
model: &dyn DecisionModel,
options: DecisionCallOptions,
) -> Result<DecisionResult, AiMuxError> {
decide_with_retry_delay(model, options, None).await
}

/// [`decide`] with an optional crate-internal override of the first retry
/// delay (tests only; `None` keeps the core default).
async fn decide_with_retry_delay(
model: &dyn DecisionModel,
mut options: DecisionCallOptions,
initial_retry_delay: Option<std::time::Duration>,
) -> Result<DecisionResult, AiMuxError> {
options.validate()?;
let capabilities = model.capabilities();
Expand Down Expand Up @@ -648,11 +653,14 @@ pub async fn decide(
}
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
let retries = retry::prepare_retries_with_config(
options.max_retries,
model.retry_config(),
abort_signal.clone(),
);
let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
#[cfg(test)]
let retries = match initial_retry_delay {
Some(delay) => retries.with_initial_delay(delay),
None => retries,
};
#[cfg(not(test))]
let _ = initial_retry_delay;
let context = crate::recording::recorder().map(|recorder| {
let ctx =
crate::recording::RecordingContext::new(crate::recording::new_call_id(), recorder);
Expand Down Expand Up @@ -721,3 +729,112 @@ pub async fn decide(
}
result
}

#[cfg(test)]
mod retry_tests {
//! Decision retries use only the core call-level `max_retries` (A6 item 2).
//! The first retry delay is set to zero through the crate-internal
//! [`retry::PreparedRetries::with_initial_delay`], so no test waits.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use super::*;
use crate::error::{ApiCallError, RetryErrorReason};

struct FailingModel {
calls: AtomicUsize,
status: u16,
retryable: bool,
}

#[async_trait]
impl DecisionModel for FailingModel {
fn provider(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"test"
}
fn capabilities(&self) -> DecisionCapabilities {
DecisionCapabilities {
supports_images: false,
max_images: None,
supports_typed_choices: false,
rounding: DecisionRounding::default(),
probability_source: DecisionProbabilitySource::ModelEstimate,
supports_boolean: true,
supports_choice: true,
supports_score: true,
returns_distributions: false,
max_questions: None,
min_choices: None,
max_choices: None,
max_score_levels: None,
}
}
async fn do_decide(&self, _: &DecisionCallOptions) -> Result<DecisionResult, AiMuxError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(self.status),
is_retryable: self.retryable,
..ApiCallError::new("failure", "https://example.test", serde_json::json!({}))
})))
}
}

fn options(max_retries: Option<u32>) -> DecisionCallOptions {
let mut options: DecisionCallOptions = serde_json::from_value(serde_json::json!({
"state": "text",
"questions": [{"id": "q", "type": "boolean", "instructions": "test"}]
}))
.unwrap();
options.max_retries = max_retries;
options
}

async fn run(status: u16, retryable: bool, max_retries: Option<u32>) -> (usize, AiMuxError) {
let model = FailingModel {
calls: AtomicUsize::new(0),
status,
retryable,
};
let error = decide_with_retry_delay(&model, options(max_retries), Some(Duration::ZERO))
.await
.unwrap_err();
(model.calls.load(Ordering::SeqCst), error)
}

#[tokio::test]
async fn call_level_max_retries_sets_exact_attempts_until_exhausted() {
for status in [429, 503, 529] {
for (max_retries, attempts) in [(Some(1), 2), (Some(3), 4), (None, 3)] {
let (calls, error) = run(status, true, max_retries).await;
assert_eq!(
calls, attempts,
"status {status}, max_retries {max_retries:?}"
);
let AiMuxError::Retry(retry) = &error else {
panic!("expected retry exhaustion, got {error:?}");
};
assert_eq!(retry.reason, RetryErrorReason::MaxRetriesExceeded);
assert_eq!(retry.errors.len(), attempts);
let AiMuxError::ApiCall(last) = retry.last_error() else {
panic!("expected API error");
};
assert_eq!(last.status_code, Some(status));
}
}
}

#[tokio::test]
async fn zero_max_retries_and_non_retryable_errors_make_one_attempt() {
let (calls, error) = run(503, true, Some(0)).await;
assert_eq!(calls, 1);
assert!(matches!(&error, AiMuxError::ApiCall(e) if e.status_code == Some(503)));
for status in [401, 422, 499] {
let (calls, error) = run(status, false, Some(3)).await;
assert_eq!(calls, 1, "status {status}");
assert!(matches!(&error, AiMuxError::ApiCall(e) if e.status_code == Some(status)));
}
}
}
50 changes: 8 additions & 42 deletions aimux-core/src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,6 @@ const DEFAULT_INITIAL_DELAY_MS: u64 = 2_000;
/// Default multiplier applied to the delay after each retry.
const DEFAULT_BACKOFF_FACTOR: u32 = 2;

/// Retry settings a decision model may carry (#226,
/// [`DecisionModel::retry_config`](crate::DecisionModel::retry_config)).
///
/// A per-call `max_retries` overrides only [`Self::max_retries`]; the
/// configured delay and backoff factor remain in effect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryConfig {
/// Maximum number of retries after the initial attempt.
pub max_retries: u32,
/// Initial delay between attempts.
pub initial_delay: Duration,
/// Multiplier applied to the delay after each retry.
pub backoff_factor: u32,
}

impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: DEFAULT_MAX_RETRIES,
initial_delay: Duration::from_millis(DEFAULT_INITIAL_DELAY_MS),
backoff_factor: DEFAULT_BACKOFF_FACTOR,
}
}
}

/// A prepared operation retry function and its resolved retry count.
///
/// This is the Rust representation of the AI SDK's
Expand Down Expand Up @@ -70,25 +45,16 @@ pub(crate) fn prepare_retries(
}
}

/// Bind a model's retry settings, applying a per-call retry-count override.
#[must_use]
pub(crate) fn prepare_retries_with_config(
max_retries: Option<u32>,
mut config: RetryConfig,
abort: Option<AbortSignal>,
) -> PreparedRetries {
if let Some(max_retries) = max_retries {
config.max_retries = max_retries;
}
PreparedRetries {
max_retries: config.max_retries,
initial_delay_ms: u64::try_from(config.initial_delay.as_millis()).unwrap_or(u64::MAX),
backoff_factor: u64::from(config.backoff_factor),
abort_signal: abort,
impl PreparedRetries {
/// Replace the delay before the first retry. Crate-internal: only this
/// crate's tests use it (zero delay); callers set nothing but `max_retries`.
#[cfg(test)]
#[must_use]
pub(crate) fn with_initial_delay(mut self, delay: Duration) -> Self {
self.initial_delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
self
}
}

impl PreparedRetries {
/// Retry a complete operation using API-call retryability and response hints.
///
/// # Errors
Expand Down
8 changes: 2 additions & 6 deletions aimux-providers/src/decision_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,12 @@ impl DecisionEndpoint {
}

/// #226's decision connection snapshot: identity, base URL, credential
/// *source* and the connection headers/retries. The credential value is
/// *source* and the connection headers. The credential value is
/// never read; sensitive headers are redacted again when recorded.
pub(crate) fn snapshot(
&self,
provider: &str,
model_id: &str,
retry_config: aimux_core::retry::RetryConfig,
profile: Value,
) -> aimux_core::recording::ProviderRecord {
use crate::shared::Credential;
Expand All @@ -73,10 +72,7 @@ impl DecisionEndpoint {
}
}
record.provider_options = Some(aimux_core::recording::redact_provider_options(
serde_json::json!({
"headers": wire_headers,
"max_retries": retry_config.max_retries,
}),
serde_json::json!({ "headers": wire_headers }),
));
record.profile = Some(profile);
record
Expand Down
6 changes: 0 additions & 6 deletions aimux-providers/src/jev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ pub struct JevConfig {
pub endpoint: String,
pub headers: HashMap<String, String>,
pub probability_source: DecisionProbabilitySource,
/// Retry settings [`decide`] uses.
pub retry_config: aimux_core::retry::RetryConfig,
}

impl JevConfig {
Expand All @@ -35,7 +33,6 @@ impl JevConfig {
endpoint: "https://api.typesafe.ai/v1/systemone".into(),
headers: HashMap::new(),
probability_source: DecisionProbabilitySource::Native,
retry_config: aimux_core::retry::RetryConfig::default(),
}
}

Expand Down Expand Up @@ -113,9 +110,6 @@ impl DecisionModel for JevDecisionModel {
));
snapshot
}
fn retry_config(&self) -> aimux_core::retry::RetryConfig {
self.config.retry_config
}
fn capabilities(&self) -> DecisionCapabilities {
DecisionCapabilities {
supports_images: false,
Expand Down
13 changes: 0 additions & 13 deletions aimux-providers/src/openai/decision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::decision_support::DecisionEndpoint;
pub struct OpenAIDecisionModel {
endpoint: DecisionEndpoint,
model_id: String,
retry_config: aimux_core::retry::RetryConfig,
}

impl OpenAIDecisionModel {
Expand All @@ -32,16 +31,8 @@ impl OpenAIDecisionModel {
Ok(Self {
endpoint: DecisionEndpoint::OpenAI(config),
model_id: model_id.into(),
retry_config: aimux_core::retry::RetryConfig::default(),
})
}

/// Replace the retry settings [`decide`] uses for this model (#226).
#[must_use]
pub fn with_retry_config(mut self, retry_config: aimux_core::retry::RetryConfig) -> Self {
self.retry_config = retry_config;
self
}
}

fn text_description(description: &DecisionDescription) -> Result<&str, AiMuxError> {
Expand Down Expand Up @@ -407,14 +398,10 @@ impl DecisionModel for OpenAIDecisionModel {
fn model_id(&self) -> &str {
&self.model_id
}
fn retry_config(&self) -> aimux_core::retry::RetryConfig {
self.retry_config
}
fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord {
self.endpoint.snapshot(
self.provider(),
self.model_id(),
self.retry_config,
json!({"decision_protocol":"openai_decisions", "decision_capabilities":self.capabilities()}),
)
}
Expand Down
Loading
Loading