use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderErrorClass {
AuthInvalid,
AuthForbidden,
RateLimited { retry_after: Option<Duration> },
Overloaded,
InvalidRequest,
NotFound,
Other,
}
impl ProviderErrorClass {
pub fn from_status(status: u16, retry_after: Option<Duration>) -> Self {
match status {
401 => Self::AuthInvalid,
403 => Self::AuthForbidden,
429 => Self::RateLimited { retry_after },
408 | 500 | 502 | 503 | 504 | 529 => Self::Overloaded,
400 | 413 | 422 => Self::InvalidRequest,
404 => Self::NotFound,
_ => Self::Other,
}
}
pub fn is_retryable(&self) -> bool {
matches!(self, Self::RateLimited { .. } | Self::Overloaded)
}
}
#[derive(Debug, thiserror::Error)]
pub enum KovaError {
#[error("Provider error: {message} (status: {status_code:?})")]
Provider {
message: String,
status_code: Option<u16>,
class: ProviderErrorClass,
},
#[error("Connection error: {0}")]
Connection(String),
#[error("Tool execution error: {tool_name}: {message}")]
ToolExecution { tool_name: String, message: String },
#[error("Tool not found: {0}")]
ToolNotFound(String),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Timeout after {0:?}")]
Timeout(Duration),
#[error("MCP error: {0}")]
Mcp(String),
#[error("Build error: {0}")]
Build(String),
#[error("Stream error: {0}")]
Stream(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Max iterations ({0}) reached in tool-call loop")]
MaxIterations(usize),
#[error("Turn cancelled")]
Cancelled,
#[error("Context budget exceeded: ~{measured} tokens > budget {budget}")]
ContextBudgetExceeded { measured: u32, budget: u32 },
}
impl KovaError {
pub fn provider_http(
status: u16,
retry_after: Option<Duration>,
message: impl Into<String>,
) -> Self {
KovaError::Provider {
message: message.into(),
status_code: Some(status),
class: ProviderErrorClass::from_status(status, retry_after),
}
}
pub fn provider_invalid(message: impl Into<String>) -> Self {
KovaError::Provider {
message: message.into(),
status_code: None,
class: ProviderErrorClass::Other,
}
}
pub fn provider_auth(message: impl Into<String>) -> Self {
KovaError::Provider {
message: message.into(),
status_code: None,
class: ProviderErrorClass::AuthInvalid,
}
}
pub fn status_code(&self) -> Option<u16> {
match self {
KovaError::Provider { status_code, .. } => *status_code,
_ => None,
}
}
pub fn provider_class(&self) -> Option<&ProviderErrorClass> {
match self {
KovaError::Provider { class, .. } => Some(class),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
KovaError::Connection(_) | KovaError::Timeout(_) => true,
KovaError::Provider { class, .. } => class.is_retryable(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_auth_statuses() {
assert_eq!(
ProviderErrorClass::from_status(401, None),
ProviderErrorClass::AuthInvalid
);
assert_eq!(
ProviderErrorClass::from_status(403, None),
ProviderErrorClass::AuthForbidden
);
}
#[test]
fn classify_rate_limit_carries_retry_after() {
let class = ProviderErrorClass::from_status(429, Some(Duration::from_secs(7)));
assert_eq!(
class,
ProviderErrorClass::RateLimited {
retry_after: Some(Duration::from_secs(7))
}
);
assert!(class.is_retryable());
}
#[test]
fn classify_transient_and_terminal_statuses() {
for status in [408, 500, 502, 503, 504, 529] {
assert_eq!(
ProviderErrorClass::from_status(status, None),
ProviderErrorClass::Overloaded,
"status {status}"
);
}
for status in [400, 413, 422] {
assert_eq!(
ProviderErrorClass::from_status(status, None),
ProviderErrorClass::InvalidRequest,
"status {status}"
);
}
assert_eq!(
ProviderErrorClass::from_status(404, None),
ProviderErrorClass::NotFound
);
assert_eq!(
ProviderErrorClass::from_status(418, None),
ProviderErrorClass::Other
);
}
#[test]
fn retryability_matches_class() {
assert!(KovaError::provider_http(429, None, "slow down").is_retryable());
assert!(KovaError::provider_http(529, None, "overloaded").is_retryable());
assert!(!KovaError::provider_http(401, None, "bad key").is_retryable());
assert!(!KovaError::provider_http(400, None, "too long").is_retryable());
assert!(!KovaError::provider_invalid("garbled").is_retryable());
}
#[test]
fn constructors_set_class() {
assert_eq!(
KovaError::provider_auth("no creds").provider_class(),
Some(&ProviderErrorClass::AuthInvalid)
);
assert_eq!(
KovaError::provider_invalid("bad json").provider_class(),
Some(&ProviderErrorClass::Other)
);
assert_eq!(
KovaError::provider_http(404, None, "no such model").provider_class(),
Some(&ProviderErrorClass::NotFound)
);
assert_eq!(
KovaError::Connection("refused".into()).provider_class(),
None
);
}
}