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) => match http_status_from_message(msg) {
Some(status) if status >= 500 => ErrorCode::HttpResponseError,
Some(_) => ErrorCode::HttpRequestError,
None => 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 {
match self {
Self::Http(msg) => http_status_is_retryable(msg),
_ => matches!(
self.code(),
ErrorCode::ApiRequestFailed
| ErrorCode::ApiRateLimited
| ErrorCode::ApiTimeout
| ErrorCode::ApiStreamError
| ErrorCode::HttpConnectionError
| 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_from_message(msg: &str) -> Option<u16> {
let rest = msg.strip_prefix("HTTP ")?;
let colon = rest.find(':')?;
rest[..colon].parse::<u16>().ok()
}
pub(crate) fn http_status_is_overload(msg: &str) -> bool {
matches!(http_status_from_message(msg), Some(429 | 503 | 529))
}
fn http_status_is_retryable(msg: &str) -> bool {
match http_status_from_message(msg) {
None => true,
Some(status) => status >= 500 || matches!(status, 408 | 429),
}
}
#[cfg(feature = "streaming")]
pub(crate) fn parse_retry_after(value: &str) -> Option<std::time::Duration> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(secs) = trimmed.parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
}
#[cfg(feature = "providers")]
{
if let Ok(target) = httpdate::parse_http_date(trimmed) {
let now = std::time::SystemTime::now();
return now
.duration_since(target)
.ok()
.map(|_| std::time::Duration::ZERO)
.or_else(|| target.duration_since(now).ok());
}
}
None
}
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(500, "server error").is_retryable());
assert!(ApiError::http_with_status(429, "too many requests").is_retryable());
assert!(ApiError::http_with_status(408, "request timeout").is_retryable());
assert!(!ApiError::http_with_status(400, "bad request").is_retryable());
assert!(!ApiError::http_with_status(404, "not found").is_retryable());
assert!(!ApiError::auth("invalid key").is_retryable());
assert!(!ApiError::tool("not found").is_retryable());
}
#[test]
fn http_retryability_set_is_explicit() {
let retryable = [429u16, 500, 502, 503, 504, 529, 408];
for status in retryable {
assert!(
ApiError::http_with_status(status, "classified").is_retryable(),
"HTTP {status} belongs to the retryable set"
);
}
let permanent = [400u16, 401, 403, 404, 405, 422, 451];
for status in permanent {
assert!(
!ApiError::http_with_status(status, "classified").is_retryable(),
"HTTP {status} is permanent — outside the retryable set"
);
}
}
#[test]
#[cfg(feature = "streaming")]
fn parse_retry_after_delta_seconds() {
assert_eq!(
parse_retry_after("12"),
Some(std::time::Duration::from_secs(12))
);
assert_eq!(parse_retry_after("0"), Some(std::time::Duration::ZERO));
assert_eq!(
parse_retry_after(" 7 "),
Some(std::time::Duration::from_secs(7))
);
}
#[test]
#[cfg(feature = "streaming")]
fn parse_retry_after_huge_value_parses_without_panicking() {
let parsed = parse_retry_after("999999999999");
assert!(parsed.is_some(), "huge value should parse, not panic");
}
#[test]
#[cfg(feature = "streaming")]
fn parse_retry_after_garbage_returns_none() {
assert_eq!(parse_retry_after("soon"), None);
assert_eq!(parse_retry_after(""), None);
assert_eq!(parse_retry_after("abc"), None);
}
#[test]
#[cfg(all(feature = "streaming", feature = "providers"))]
fn parse_retry_after_http_date() {
let parsed = parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT");
assert!(parsed.is_some(), "HTTP-date should parse under providers");
}
#[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());
}
}