use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ApiCallError {
#[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 request_id: Option<String>,
#[serde(default)]
#[ts(type = "number | null")]
pub retry_after_ms: Option<u64>,
#[serde(default)]
pub is_retryable: bool,
}
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(ApiCallError),
#[error("JSON parse error: {0}")]
JsonParse(String),
#[error("invalid response data: {0}")]
InvalidResponseData(String),
#[error("tool error: {0}")]
Tool(String),
#[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("request aborted")]
Aborted,
#[error("{0}")]
Other(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()),
}
}
}
impl AiMuxError {
#[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> {
match self {
AiMuxError::ApiCall(d) => d.retry_after_ms.map(|ms| ms as i64),
_ => None,
}
}
#[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::*;
#[test]
fn bare_constructors_report_no_status() {
assert_eq!(
AiMuxError::ApiCall(ApiCallError {
message: "no api key".into(),
..Default::default()
})
.status_code(),
None
);
assert_eq!(
AiMuxError::ApiCall(ApiCallError {
message: "reset".into(),
is_retryable: true,
..Default::default()
})
.status_code(),
None
);
assert_eq!(
AiMuxError::ApiCall(ApiCallError {
message: "boom".into(),
..Default::default()
})
.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(ApiCallError {
status_code: Some(403),
message: "forbidden".into(),
..Default::default()
})
.status_code(),
Some(403)
);
assert_eq!(
AiMuxError::ApiCall(ApiCallError {
message: "HTTP 403: forbidden".into(),
..Default::default()
})
.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(ApiCallError {
message: "connection reset".into(),
is_retryable: true,
..Default::default()
})
.is_retryable()
);
assert!(!AiMuxError::JsonParse("parse".into()).is_retryable());
}
#[test]
fn rate_limit_is_read_from_the_field() {
let err = AiMuxError::ApiCall(ApiCallError {
status_code: Some(429),
message: "quota exceeded".into(),
retry_after_ms: Some(7),
is_retryable: true,
..Default::default()
});
assert!(err.is_retryable());
assert_eq!(err.retry_after_hint(), Some(7));
assert!(
!AiMuxError::ApiCall(ApiCallError {
message: "boom".into(),
..Default::default()
})
.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));
}
}