use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ApiCallError {
pub url: String,
pub request_body_values: serde_json::Value,
#[serde(default)]
pub status_code: Option<u16>,
#[serde(default)]
pub provider_code: Option<String>,
pub message: String,
#[serde(default)]
pub response_body: Option<String>,
#[serde(default)]
pub response_headers: Option<HashMap<String, String>>,
#[serde(default)]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub is_retryable: bool,
}
impl ApiCallError {
pub fn new(
message: impl Into<String>,
url: impl Into<String>,
request_body_values: serde_json::Value,
) -> Self {
Self {
url: url.into(),
request_body_values,
status_code: None,
provider_code: None,
message: message.into(),
response_body: None,
response_headers: None,
data: None,
is_retryable: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum RetryErrorReason {
MaxRetriesExceeded,
ErrorNotRetryable,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct RetryError {
pub reason: RetryErrorReason,
pub errors: Vec<AiMuxError>,
}
static EMPTY_RETRY_HISTORY_ERROR: LazyLock<AiMuxError> =
LazyLock::new(|| AiMuxError::Other("Retry failed without recorded errors".into()));
impl RetryError {
#[must_use]
pub fn last_error(&self) -> &AiMuxError {
match self.errors.last() {
Some(error) => error,
None => &EMPTY_RETRY_HISTORY_ERROR,
}
}
}
impl std::fmt::Display for RetryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let attempts = self.errors.len();
let Some(last_error) = self.errors.last() else {
return f.write_str("Retry failed without recorded errors");
};
match self.reason {
RetryErrorReason::MaxRetriesExceeded => {
write!(
f,
"Failed after {attempts} attempts. Last error: {last_error}"
)
}
RetryErrorReason::ErrorNotRetryable => write!(
f,
"Failed after {attempts} attempts with non-retryable error: '{last_error}'"
),
}
}
}
impl std::fmt::Display for ApiCallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.status_code, self.message.is_empty()) {
(Some(status), false) => write!(f, "HTTP {}: {}", status, self.message),
(Some(status), true) => write!(f, "HTTP {status}"),
(None, _) => f.write_str(&self.message),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)]
#[ts(export)]
pub enum AiMuxError {
#[error("API call error: {0}")]
ApiCall(Box<ApiCallError>),
#[error("{0}")]
Retry(RetryError),
#[error("JSON parse error: {0}")]
JsonParse(String),
#[error("invalid response data: {0}")]
InvalidResponseData(String),
#[error("Model tried to call unavailable tool '{tool_name}'. {}", no_such_tool_availability(.available_tools))]
NoSuchTool {
tool_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_input: Option<String>,
},
#[error("Invalid input for tool {tool_name}: {cause}")]
InvalidToolInput {
tool_name: String,
tool_input: String,
cause: String,
},
#[error("Error repairing tool call: {cause}")]
ToolCallRepair {
original_error: Box<AiMuxError>,
cause: Box<AiMuxError>,
},
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("invalid prompt: {0}")]
InvalidPrompt(String),
#[error("token expired: {0}")]
TokenExpired(String),
#[error("unsupported functionality: {0}")]
UnsupportedFunctionality(String),
#[error("no such model: {model_id}")]
NoSuchModel {
model_id: String,
#[serde(default)]
model_type: String,
},
#[error("No such provider: {provider_id}")]
NoSuchProvider { provider_id: String },
#[error("request timed out: {0}")]
Timeout(String),
#[error("{0}")]
Aborted(String),
#[error("{0}")]
Other(String),
}
fn no_such_tool_availability(available_tools: &Option<Vec<String>>) -> String {
match available_tools {
Some(tools) => format!("Available tools: {}.", tools.join(", ")),
None => "No tools are available.".to_string(),
}
}
impl From<serde_json::Error> for AiMuxError {
fn from(e: serde_json::Error) -> Self {
use serde_json::error::Category;
match e.classify() {
Category::Data => AiMuxError::InvalidResponseData(e.to_string()),
Category::Syntax | Category::Eof | Category::Io => AiMuxError::JsonParse(e.to_string()),
}
}
}
#[must_use]
pub fn is_retryable_status(status: u16) -> bool {
matches!(status, 408 | 409 | 429) || status >= 500
}
impl AiMuxError {
#[must_use]
pub fn is_recoverable_stream_error(&self) -> bool {
matches!(self, Self::JsonParse(_) | Self::InvalidResponseData(_))
}
#[must_use]
pub fn from_abort_signal(_signal: &crate::AbortSignal) -> Self {
Self::Aborted("request aborted".into())
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
AiMuxError::ApiCall(d) => d.is_retryable,
_ => false,
}
}
#[must_use]
pub fn retry_after_hint(&self) -> Option<i64> {
let AiMuxError::ApiCall(detail) = self else {
return None;
};
let headers = detail.response_headers.as_ref()?;
headers
.get("retry-after-ms")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| !value.is_nan())
.map(|value| value as i64)
.or_else(|| {
headers.get("retry-after").and_then(|value| {
value
.parse::<f64>()
.ok()
.filter(|value| !value.is_nan())
.map(|seconds| (seconds * 1_000.0) as i64)
.or_else(|| {
httpdate::parse_http_date(value).ok().map(|date| {
match date.duration_since(SystemTime::now()) {
Ok(duration) => duration.as_millis() as i64,
Err(error) => -(error.duration().as_millis() as i64),
}
})
})
})
})
.filter(|milliseconds| *milliseconds >= 0)
}
#[must_use]
pub fn status_code(&self) -> Option<u16> {
match self {
AiMuxError::TokenExpired(_) => Some(401),
AiMuxError::ApiCall(d) => d.status_code,
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api_error(message: &str) -> ApiCallError {
ApiCallError::new(message, "https://example.test", serde_json::json!({}))
}
#[test]
fn bare_constructors_report_no_status() {
assert_eq!(
AiMuxError::ApiCall(Box::new(api_error("no api key"))).status_code(),
None
);
assert_eq!(
AiMuxError::ApiCall(Box::new(ApiCallError {
is_retryable: true,
..api_error("reset")
}))
.status_code(),
None
);
assert_eq!(
AiMuxError::ApiCall(Box::new(api_error("boom"))).status_code(),
None
);
}
#[test]
fn token_expired_is_a_401_by_contract() {
assert_eq!(
AiMuxError::TokenExpired("expired".into()).status_code(),
Some(401)
);
assert!(!AiMuxError::TokenExpired("expired".into()).is_retryable());
}
#[test]
fn status_code_reads_the_field_not_the_message() {
assert_eq!(
AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(403),
..api_error("forbidden")
}))
.status_code(),
Some(403)
);
assert_eq!(
AiMuxError::ApiCall(Box::new(api_error("HTTP 403: forbidden"))).status_code(),
None
);
assert_eq!(
AiMuxError::Timeout("HTTP 408: total timeout after 1000ms".into()).status_code(),
None
);
assert_eq!(AiMuxError::JsonParse("HTTP 400".into()).status_code(), None);
assert_eq!(AiMuxError::Other("HTTP 400".into()).status_code(), None);
}
#[test]
fn timeout_is_not_retryable_transport_is() {
assert!(!AiMuxError::Timeout("total timeout".into()).is_retryable());
assert!(
AiMuxError::ApiCall(Box::new(ApiCallError {
is_retryable: true,
..api_error("connection reset")
}))
.is_retryable()
);
assert!(!AiMuxError::JsonParse("parse".into()).is_retryable());
}
#[test]
fn empty_retry_history_round_trips_without_panicking() {
let json = r#"{"reason":"maxRetriesExceeded","errors":[]}"#;
let error: RetryError = serde_json::from_str(json).unwrap();
assert!(error.errors.is_empty());
assert_eq!(error.to_string(), "Retry failed without recorded errors");
assert_eq!(
error.last_error().to_string(),
"Retry failed without recorded errors"
);
assert_eq!(serde_json::to_string(&error).unwrap(), json);
}
#[test]
fn rate_limit_is_read_from_the_field() {
let err = AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(429),
response_headers: Some(HashMap::from([("retry-after-ms".into(), "7".into())])),
is_retryable: true,
..api_error("quota exceeded")
}));
assert!(err.is_retryable());
assert_eq!(err.retry_after_hint(), Some(7));
assert!(!AiMuxError::ApiCall(Box::new(api_error("boom"))).is_retryable());
let json = serde_json::to_string(&err).unwrap();
let back: AiMuxError = serde_json::from_str(&json).unwrap();
assert_eq!(back.retry_after_hint(), Some(7));
assert_eq!(back.status_code(), Some(429));
}
}