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("Rate limit exceeded (retry after {retry_after:?}): {message}")]
RateLimit {
retry_after: Option<std::time::Duration>,
message: 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::RateLimit { .. } => ErrorCode::ApiRateLimited,
Self::Http(msg) => {
if let Some(rest) = msg.strip_prefix("HTTP ")
&& let Some(colon) = rest.find(':')
&& 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 fn is_rate_limited(&self) -> bool {
match self {
Self::RateLimit { .. } => true,
Self::Api(msg) => {
let lower = msg.to_lowercase();
lower.contains("rate limit") || lower.contains("429")
}
Self::Http(msg) => http_status_is_overload(msg),
_ => false,
}
}
#[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())
}
#[must_use]
pub fn rate_limited(
message: impl Into<String>,
retry_after: Option<std::time::Duration>,
) -> Self {
Self::RateLimit {
message: message.into(),
retry_after,
}
}
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(crate) fn http_status_is_overload(msg: &str) -> bool {
let Some(rest) = msg.strip_prefix("HTTP ") else {
return false;
};
let Some(colon) = rest.find(':') else {
return false;
};
matches!(rest[..colon].parse::<u16>().ok(), Some(429 | 503 | 529))
}
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::rate_limited("too many requests", None).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_rate_limited_carrier_code() {
let with_hint = ApiError::rate_limited(
"429 too many requests",
Some(std::time::Duration::from_secs(12)),
);
assert_eq!(with_hint.code(), ErrorCode::ApiRateLimited);
assert!(with_hint.is_retryable());
let no_hint = ApiError::rate_limited("slow down", None);
assert_eq!(no_hint.code(), ErrorCode::ApiRateLimited);
assert!(no_hint.is_retryable());
assert!(no_hint.is_rate_limited());
}
#[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);
}
#[test]
fn http_status_is_overload_detects_429_503_529() {
assert!(http_status_is_overload("HTTP 429: Too Many Requests"));
assert!(http_status_is_overload("HTTP 503: Service Unavailable"));
assert!(http_status_is_overload("HTTP 529: Overloaded"));
}
#[test]
fn http_status_is_overload_rejects_other_statuses() {
assert!(!http_status_is_overload("HTTP 500: Internal Server Error"));
assert!(!http_status_is_overload("HTTP 504: Gateway Timeout"));
assert!(!http_status_is_overload("HTTP 404: Not Found"));
assert!(!http_status_is_overload("HTTP 200: OK"));
}
#[test]
fn http_status_is_overload_rejects_malformed_prefix() {
assert!(!http_status_is_overload("https 429: not the prefix"));
assert!(!http_status_is_overload("429 Too Many Requests"));
assert!(!http_status_is_overload("HTTP 429"));
assert!(!http_status_is_overload("HTTP abc: non-numeric"));
assert!(!http_status_is_overload(""));
}
#[test]
fn is_rate_limited_true_for_structured_variant() {
assert!(
ApiError::RateLimit {
retry_after: None,
message: "slow down".into(),
}
.is_rate_limited()
);
assert!(
ApiError::RateLimit {
retry_after: Some(std::time::Duration::from_secs(7)),
message: "slow down".into(),
}
.is_rate_limited()
);
}
#[test]
fn is_rate_limited_true_for_api_body_with_rate_limit_text() {
assert!(ApiError::api("rate limit exceeded").is_rate_limited());
assert!(ApiError::api("Rate Limit Exceeded").is_rate_limited());
assert!(ApiError::api("HTTP 429 from upstream").is_rate_limited());
assert!(!ApiError::api("connection reset").is_rate_limited());
assert!(!ApiError::api("timeout").is_rate_limited());
}
#[test]
fn is_rate_limited_true_for_http_overload_statuses() {
assert!(ApiError::http_with_status(429, "Too Many Requests").is_rate_limited());
assert!(ApiError::http_with_status(503, "Service Unavailable").is_rate_limited());
assert!(ApiError::http_with_status(529, "Overloaded").is_rate_limited());
assert!(!ApiError::http_with_status(500, "Internal Server Error").is_rate_limited());
assert!(!ApiError::http_with_status(404, "Not Found").is_rate_limited());
}
#[test]
fn is_rate_limited_false_for_other_variants() {
assert!(!ApiError::auth("bad key").is_rate_limited());
assert!(!ApiError::tool("execution failed").is_rate_limited());
assert!(!ApiError::config("missing field").is_rate_limited());
assert!(!ApiError::Other("misc".into()).is_rate_limited());
assert!(!ApiError::Interrupted.is_rate_limited());
}
}