use serde_repr::{Deserialize_repr, Serialize_repr};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)]
#[repr(u16)]
#[non_exhaustive]
pub enum ErrorCode {
ApiRequestFailed = 1000,
ApiResponseInvalid = 1001,
ApiRateLimited = 1002,
ApiTimeout = 1003,
ApiStreamError = 1004,
ApiContextOverflow = 1005,
AuthInvalidKey = 1100,
AuthFailed = 1101,
HttpConnectionError = 1200,
HttpRequestError = 1201,
HttpResponseError = 1202,
ToolNotFound = 1300,
ToolExecutionFailed = 1301,
ToolPermissionDenied = 1302,
ToolInputInvalid = 1303,
ToolTimeout = 1304,
ConfigFileNotFound = 1400,
ConfigParseError = 1401,
ConfigValidationError = 1402,
ConfigMissing = 1403,
IoFileNotFound = 1500,
IoReadError = 1501,
IoWriteError = 1502,
JsonParseError = 1600,
InternalError = 1700,
Interrupted = 1999,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ApiError {
#[error("API error: {0}")]
Api(String),
#[error("Authentication error: {0}")]
Auth(String),
#[error("HTTP error: {0}")]
Http(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Tool error: {0}")]
Tool(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Interrupted")]
Interrupted,
#[error("Other: {0}")]
Other(String),
}
impl ApiError {
#[must_use]
pub fn code(&self) -> ErrorCode {
match self {
Self::Api(msg) => {
let msg_lower = msg.to_lowercase();
if msg_lower.contains("timeout") {
ErrorCode::ApiTimeout
} else if msg_lower.contains("rate limit") || msg_lower.contains("429") {
ErrorCode::ApiRateLimited
} else if msg_lower.contains("stream") {
ErrorCode::ApiStreamError
} else if Self::is_context_overflow_internal(msg) {
ErrorCode::ApiContextOverflow
} else {
ErrorCode::ApiRequestFailed
}
}
Self::Auth(msg) => {
if msg.to_lowercase().contains("invalid") {
ErrorCode::AuthInvalidKey
} else {
ErrorCode::AuthFailed
}
}
Self::Http(msg) => {
if let Some(rest) = msg.strip_prefix("HTTP ") {
if let Some(colon) = rest.find(':') {
if let Ok(status) = rest[..colon].parse::<u16>() {
return if status >= 500 {
ErrorCode::HttpResponseError
} else {
ErrorCode::HttpRequestError
};
}
}
}
ErrorCode::HttpConnectionError
}
Self::Json(_) => ErrorCode::JsonParseError,
Self::Io(err) => {
use std::io::ErrorKind;
match err.kind() {
ErrorKind::NotFound => ErrorCode::IoFileNotFound,
ErrorKind::PermissionDenied | ErrorKind::WriteZero => ErrorCode::IoWriteError,
_ => ErrorCode::IoReadError,
}
}
Self::Tool(msg) => {
let msg_lower = msg.to_lowercase();
if msg_lower.contains("not found") {
ErrorCode::ToolNotFound
} else if msg_lower.contains("permission") || msg_lower.contains("denied") {
ErrorCode::ToolPermissionDenied
} else if msg_lower.contains("timeout") {
ErrorCode::ToolTimeout
} else if msg_lower.contains("invalid") {
ErrorCode::ToolInputInvalid
} else {
ErrorCode::ToolExecutionFailed
}
}
Self::Config(msg) => {
let msg_lower = msg.to_lowercase();
if msg_lower.contains("not found") {
ErrorCode::ConfigFileNotFound
} else if msg_lower.contains("validation") {
ErrorCode::ConfigValidationError
} else if msg_lower.contains("missing") {
ErrorCode::ConfigMissing
} else {
ErrorCode::ConfigParseError
}
}
Self::Interrupted => ErrorCode::Interrupted,
Self::Other(_) => ErrorCode::InternalError,
}
}
#[must_use]
pub fn is_context_overflow(&self) -> bool {
match self {
Self::Api(msg) | Self::Other(msg) => Self::is_context_overflow_internal(msg),
_ => false,
}
}
fn is_context_overflow_internal(msg: &str) -> bool {
let msg_lower = msg.to_lowercase();
msg_lower.contains("context")
|| msg_lower.contains("too many tokens")
|| msg_lower.contains("exceeds maximum")
|| msg_lower.contains("max tokens")
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(
self.code(),
ErrorCode::ApiRequestFailed
| ErrorCode::ApiRateLimited
| ErrorCode::ApiTimeout
| ErrorCode::HttpConnectionError
| ErrorCode::HttpRequestError
| ErrorCode::HttpResponseError
)
}
#[must_use]
pub const fn is_auth_error(&self) -> bool {
matches!(self, Self::Auth(..))
}
#[must_use]
pub const fn is_config_error(&self) -> bool {
matches!(self, Self::Config(..))
}
#[must_use]
pub const fn is_io_error(&self) -> bool {
matches!(self, Self::Io(..))
}
#[must_use]
pub const fn is_tool_error(&self) -> bool {
matches!(self, Self::Tool(..))
}
pub fn api(msg: impl Into<String>) -> Self {
Self::Api(msg.into())
}
pub fn auth(msg: impl Into<String>) -> Self {
Self::Auth(msg.into())
}
pub fn auth_invalid_key(msg: impl Into<String>) -> Self {
Self::Auth(format!("Invalid API key: {}", msg.into()))
}
pub fn http(msg: impl Into<String>) -> Self {
Self::Http(msg.into())
}
pub fn http_with_status(status: u16, msg: impl Into<String>) -> Self {
Self::Http(format!("HTTP {status}: {}", msg.into()))
}
pub fn tool(msg: impl Into<String>) -> Self {
Self::Tool(msg.into())
}
pub fn tool_with_name(tool: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Tool(format!("{}: {}", tool.into(), msg.into()))
}
pub fn tool_not_found(tool: impl Into<String>) -> Self {
Self::Tool(format!("Tool not found: {}", tool.into()))
}
pub fn tool_permission(tool: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Tool(format!("{} permission denied: {}", tool.into(), msg.into()))
}
pub fn tool_input_invalid(tool: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Tool(format!("{} invalid input: {}", tool.into(), msg.into()))
}
pub fn config(msg: impl Into<String>) -> Self {
Self::Config(msg.into())
}
pub fn config_not_found(path: impl Into<String>) -> Self {
Self::Config(format!("Configuration file not found: {}", path.into()))
}
pub fn config_validation(msg: impl Into<String>) -> Self {
Self::Config(format!("Configuration validation failed: {}", msg.into()))
}
pub fn api_timeout(msg: impl Into<String>) -> Self {
Self::Api(format!("Request timeout: {}", msg.into()))
}
#[must_use]
pub fn api_rate_limited() -> Self {
Self::Api("Rate limit exceeded, please retry after a moment".into())
}
pub fn api_stream(msg: impl Into<String>) -> Self {
Self::Api(format!("Stream error: {}", msg.into()))
}
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
#[must_use]
pub fn io_not_found(err: std::io::Error) -> Self {
Self::Io(err)
}
#[must_use]
pub fn io_read(err: std::io::Error) -> Self {
Self::Io(err)
}
#[must_use]
pub fn io_write(err: std::io::Error) -> Self {
Self::Io(err)
}
pub fn from_hyper<E: std::error::Error>(e: E) -> Self {
Self::Http(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, ApiError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_error_code() {
let error = ApiError::api("API failed");
assert!(error.to_string().contains("API error"));
assert!(error.to_string().contains("API failed"));
assert_eq!(error.code(), ErrorCode::ApiRequestFailed);
}
#[test]
fn test_auth_error() {
let error = ApiError::auth("Invalid key");
assert!(error.to_string().contains("Authentication error"));
assert_eq!(error.code(), ErrorCode::AuthInvalidKey);
assert!(error.is_auth_error());
}
#[test]
fn test_auth_invalid_key() {
let error = ApiError::auth_invalid_key("expired");
assert_eq!(error.code(), ErrorCode::AuthInvalidKey);
}
#[test]
fn test_auth_failed_generic() {
let error = ApiError::auth("token expired");
assert_eq!(error.code(), ErrorCode::AuthFailed);
}
#[test]
fn test_http_error() {
let error = ApiError::http("Connection failed");
assert!(error.to_string().contains("HTTP error"));
assert_eq!(error.code(), ErrorCode::HttpConnectionError);
}
#[test]
fn test_http_error_with_status() {
let error = ApiError::http_with_status(500, "Server error");
assert!(error.to_string().contains("HTTP 500"));
assert!(error.to_string().contains("Server error"));
assert_eq!(error.code(), ErrorCode::HttpResponseError);
let error = ApiError::http_with_status(429, "Too many requests");
assert_eq!(error.code(), ErrorCode::HttpRequestError);
let error = ApiError::http("Connection refused");
assert_eq!(error.code(), ErrorCode::HttpConnectionError);
let error = ApiError::http_with_status(399, "redirect-ish");
assert_eq!(error.code(), ErrorCode::HttpRequestError);
let error = ApiError::Http("generic transport failure".into());
assert_eq!(error.code(), ErrorCode::HttpConnectionError);
let error = ApiError::Http("HTTP abc: not a number".into());
assert_eq!(error.code(), ErrorCode::HttpConnectionError);
}
#[test]
fn test_tool_error() {
let error = ApiError::tool("Execution failed");
assert!(error.to_string().contains("Tool error"));
assert_eq!(error.code(), ErrorCode::ToolExecutionFailed);
assert!(error.is_tool_error());
}
#[test]
fn test_tool_not_found() {
let error = ApiError::tool_not_found("Bash");
assert!(error.to_string().contains("Tool not found"));
assert_eq!(error.code(), ErrorCode::ToolNotFound);
}
#[test]
fn test_tool_permission() {
let error = ApiError::tool_permission("Write", "no access");
assert_eq!(error.code(), ErrorCode::ToolPermissionDenied);
}
#[test]
fn test_tool_input_invalid() {
let error = ApiError::tool_input_invalid("Read", "bad path");
assert_eq!(error.code(), ErrorCode::ToolInputInvalid);
}
#[test]
fn test_config_error() {
let error = ApiError::config("Invalid config");
assert!(error.to_string().contains("Configuration error"));
assert_eq!(error.code(), ErrorCode::ConfigParseError);
assert!(error.is_config_error());
}
#[test]
fn test_config_not_found() {
let error = ApiError::config_not_found("/etc/config.toml");
assert_eq!(error.code(), ErrorCode::ConfigFileNotFound);
}
#[test]
fn test_config_validation() {
let error = ApiError::config_validation("missing field");
assert_eq!(error.code(), ErrorCode::ConfigValidationError);
}
#[test]
fn test_context_overflow_detection() {
let error = ApiError::api("Context length exceeded");
assert!(error.is_context_overflow());
assert_eq!(error.code(), ErrorCode::ApiContextOverflow);
let error = ApiError::api("max tokens reached");
assert!(error.is_context_overflow());
let error = ApiError::api("too many tokens in request");
assert!(error.is_context_overflow());
let error = ApiError::api("normal error");
assert!(!error.is_context_overflow());
}
#[test]
fn test_retryable_errors() {
assert!(ApiError::api_timeout("timed out").is_retryable());
assert!(ApiError::api_rate_limited().is_retryable());
assert!(ApiError::http("connection failed").is_retryable());
assert!(ApiError::http_with_status(503, "unavailable").is_retryable());
assert!(ApiError::http_with_status(429, "too many requests").is_retryable());
assert!(!ApiError::auth("invalid key").is_retryable());
assert!(!ApiError::tool("not found").is_retryable());
}
#[test]
fn test_error_codes_numeric() {
assert_eq!(ErrorCode::ApiRequestFailed as u32, 1000);
assert_eq!(ErrorCode::ApiRateLimited as u32, 1002);
assert_eq!(ErrorCode::ApiTimeout as u32, 1003);
assert_eq!(ErrorCode::AuthFailed as u32, 1101);
assert_eq!(ErrorCode::ToolExecutionFailed as u32, 1301);
assert_eq!(ErrorCode::Interrupted as u32, 1999);
}
#[test]
fn test_from_json_error() {
let json_result: std::result::Result<serde_json::Value, _> =
serde_json::from_str("{invalid}");
let error: ApiError = json_result.unwrap_err().into();
assert!(matches!(error, ApiError::Json(_)));
assert_eq!(error.code(), ErrorCode::JsonParseError);
}
#[test]
fn test_from_io_error() {
let error: ApiError =
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found").into();
assert!(matches!(error, ApiError::Io(_)));
assert_eq!(error.code(), ErrorCode::IoFileNotFound);
assert!(error.is_io_error());
let error: ApiError =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "no access").into();
assert_eq!(error.code(), ErrorCode::IoWriteError);
let error: ApiError =
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated").into();
assert_eq!(error.code(), ErrorCode::IoReadError);
let error: ApiError =
std::io::Error::new(std::io::ErrorKind::WriteZero, "disk full").into();
assert_eq!(error.code(), ErrorCode::IoWriteError);
}
#[test]
fn test_result_type() {
fn ok_path() -> super::Result<String> {
Ok("success".to_string())
}
fn err_path() -> super::Result<String> {
Err(ApiError::other("failure"))
}
assert_eq!(ok_path().unwrap(), "success");
assert!(err_path().is_err());
}
#[test]
fn test_api_stream_error() {
let error = ApiError::api_stream("connection reset");
assert_eq!(error.code(), ErrorCode::ApiStreamError);
}
#[test]
fn test_tool_with_name() {
let error = ApiError::tool_with_name("Read", "file not found");
assert!(error.to_string().contains("Read"));
assert!(error.to_string().contains("file not found"));
}
#[test]
fn test_from_hyper() {
let error = ApiError::from_hyper(std::io::Error::other("oops"));
assert!(matches!(error, ApiError::Http(_)));
}
#[test]
fn test_other_error() {
let error = ApiError::other("something went wrong");
assert!(error.to_string().contains("something went wrong"));
assert_eq!(error.code(), ErrorCode::InternalError);
}
#[test]
fn test_error_code_serialization() {
let code = ErrorCode::ApiRequestFailed;
let json = serde_json::to_string(&code).unwrap();
assert_eq!(
json, "1000",
"ErrorCode should serialize as numeric discriminant"
);
let back: ErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, back);
let code = ErrorCode::ToolPermissionDenied;
let json = serde_json::to_string(&code).unwrap();
assert_eq!(json, "1302");
let back: ErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, back);
let code = ErrorCode::Interrupted;
let json = serde_json::to_string(&code).unwrap();
assert_eq!(json, "1999");
let back: ErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, back);
}
}