use std::sync::{Arc, LazyLock};
use regex::Regex;
use thiserror::Error;
fn built_in_regex(pattern: &str) -> Regex {
Regex::new(pattern)
.unwrap_or_else(|error| panic!("invalid built-in regex {pattern:?}: {error}"))
}
static API_KEY_PATTERN: LazyLock<Regex> =
LazyLock::new(|| built_in_regex(r"[a-zA-Z0-9_-]{3,}\.[a-zA-Z0-9_-]{10,}"));
static SENSITIVE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
[
(r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
(r"(?i)(password\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
(r"(?i)(token\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
(r"(?i)(secret\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
(r"(?i)(\bbearer\s+)[^\s,]+", "$1[FILTERED]"),
(
r"(?i)authorization\s*:\s*Bearer\s+[^\s,]+",
"[AUTH_REDACTED]",
),
]
.into_iter()
.map(|(pattern, replacement)| (built_in_regex(pattern), replacement))
.collect()
});
static CONTAINS_SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
[
r"(?i)api[_-]?key\s*[=:]",
r"(?i)password\s*[=:]",
r"(?i)token\s*[=:]",
r"(?i)secret\s*[=:]",
r"(?i)\bbearer\s+[^\s,]+",
r"(?i)authorization\s*:\s*Bearer",
]
.into_iter()
.map(built_in_regex)
.collect()
});
pub fn mask_sensitive_info(text: &str) -> String {
let mut result = API_KEY_PATTERN.replace_all(text, "[FILTERED]").into_owned();
for (re, replacement) in SENSITIVE_PATTERNS.iter() {
result = re.replace_all(&result, *replacement).into_owned();
}
result
}
pub fn mask_api_key(text: &str) -> String {
API_KEY_PATTERN.replace_all(text, "[FILTERED]").into_owned()
}
pub fn contains_sensitive_info(text: &str) -> bool {
if API_KEY_PATTERN.is_match(text) {
return true;
}
CONTAINS_SENSITIVE_PATTERNS
.iter()
.any(|re| re.is_match(text))
}
pub fn validate_api_key(api_key: &str) -> ZaiResult<()> {
if api_key.is_empty() {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key cannot be empty".to_string(),
});
}
let Some((id, secret)) = api_key.split_once('.') else {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key must be in format '<id>.<secret>'".to_string(),
});
};
if secret.contains('.') {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key must contain exactly one dot".to_string(),
});
}
if id.is_empty() || secret.is_empty() {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key id and secret must not be empty".to_string(),
});
}
let valid_chars = |part: &str| {
part.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
};
if !valid_chars(id) || !valid_chars(secret) {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key contains invalid characters".to_string(),
});
}
if id.len() < 3 {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key id is too short".to_string(),
});
}
if secret.len() < 10 {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key secret is too short".to_string(),
});
}
Ok(())
}
pub mod codes {
pub const SDK_VALIDATION: u16 = 9001;
pub const SDK_CONFIG: u16 = 9600;
pub const SDK_FILE_NOT_FOUND: u16 = 9100;
pub const SDK_FILE_TOO_LARGE: u16 = 9101;
pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
pub const SDK_IO: u16 = 9400;
pub const SDK_TIMEOUT: u16 = 9300;
pub const SDK_EXTERNAL_TOOL: u16 = 9500;
}
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum ZaiError {
#[error("HTTP error [{status}]: {message}")]
HttpError {
status: u16,
message: String,
},
#[error("Authentication error [{code}]: {message}")]
AuthError {
code: u16,
message: String,
},
#[error("Account error [{code}]: {message}")]
AccountError {
code: u16,
message: String,
},
#[error("API error [{code}]: {message}")]
ApiError {
code: u16,
message: String,
},
#[error("Rate limit error [{code}]: {message}")]
RateLimitError {
code: u16,
message: String,
},
#[error("Content policy error [{code}]: {message}")]
ContentPolicyError {
code: u16,
message: String,
},
#[error("File error [{code}]: {message}")]
FileError {
code: u16,
message: String,
},
#[error("Network error: {0}")]
NetworkError(#[source] Arc<reqwest::Error>),
#[error("JSON error: {0}")]
JsonError(#[source] Arc<serde_json::Error>),
#[error("Realtime error: {0}")]
RealtimeError(#[source] Arc<RealtimeErrorKind>),
#[error("Realtime auth error: {0}")]
RealtimeAuthError(String),
#[error("Unknown error [{code}]: {message}")]
Unknown {
code: u16,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorCategory {
Client,
Server,
RateLimit,
Network,
Auth,
Serialization,
Other,
}
fn classify_status(status: u16) -> ErrorCategory {
match status {
429 => ErrorCategory::RateLimit,
s if (400..500).contains(&s) => ErrorCategory::Client,
s if (500..600).contains(&s) && !matches!(s, 501 | 505) => ErrorCategory::Server,
_ => ErrorCategory::Other,
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RealtimeErrorKind {
#[cfg(feature = "realtime")]
#[error("websocket: {source}")]
WebSocket {
#[source]
source: tokio_tungstenite::tungstenite::Error,
},
#[error("protocol: {0}")]
Protocol(String),
#[error("{operation} timed out")]
Timeout {
operation: &'static str,
},
#[error("session closed")]
Closed,
}
impl ZaiError {
pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
let api_message = mask_sensitive_info(&api_message);
if api_code != 0 {
return match api_code {
1000 | 1001 | 1003 | 1005 | 1220 => ZaiError::AuthError {
code: api_code,
message: api_message,
},
1113 => ZaiError::RateLimitError {
code: api_code,
message: api_message,
},
1110..=1121 => ZaiError::AccountError {
code: api_code,
message: api_message,
},
1200..=1234 | 1261 => ZaiError::ApiError {
code: api_code,
message: api_message,
},
1301 => ZaiError::ContentPolicyError {
code: api_code,
message: api_message,
},
1302 | 1305 | 1308..=1311 | 1313..=1321 => ZaiError::RateLimitError {
code: api_code,
message: api_message,
},
1400..=1499 => ZaiError::FileError {
code: api_code,
message: api_message,
},
_ => ZaiError::Unknown {
code: api_code,
message: message_or(api_message, "Unknown error"),
},
};
}
match status {
400 => ZaiError::HttpError {
status,
message: message_or(api_message, "Bad request - check your parameters"),
},
401 | 403 => ZaiError::AuthError {
code: status,
message: message_or(api_message, "Unauthorized - check your API key"),
},
404 => ZaiError::HttpError {
status,
message: message_or(api_message, "Not found - requested resource doesn't exist"),
},
429 => ZaiError::RateLimitError {
code: status,
message: message_or(api_message, "Too many requests - rate limit exceeded"),
},
434 => ZaiError::HttpError {
status,
message: message_or(api_message, "No API permission - feature not available"),
},
435 => ZaiError::HttpError {
status,
message: message_or(api_message, "File size exceeds 100MB limit"),
},
s if (500..600).contains(&s) => ZaiError::HttpError {
status,
message: if api_message.is_empty() {
format!("Server error (HTTP {status}) - try again later")
} else {
api_message
},
},
s if (400..500).contains(&s) => ZaiError::HttpError {
status,
message: message_or(api_message, "HTTP client error"),
},
_ => ZaiError::Unknown {
code: status,
message: message_or(api_message, "Unknown error"),
},
}
}
pub fn is_rate_limit(&self) -> bool {
self.category() == ErrorCategory::RateLimit
}
pub fn is_auth_error(&self) -> bool {
self.category() == ErrorCategory::Auth
}
pub fn category(&self) -> ErrorCategory {
match self {
ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
ZaiError::NetworkError(_) => ErrorCategory::Network,
ZaiError::ApiError { code, .. } if *code == codes::SDK_TIMEOUT => {
ErrorCategory::Network
},
ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
ZaiError::ApiError { code, .. } if is_server_business_code(*code) => {
ErrorCategory::Server
},
ZaiError::AccountError { .. }
| ZaiError::ApiError { .. }
| ZaiError::ContentPolicyError { .. }
| ZaiError::FileError { .. } => ErrorCategory::Client,
ZaiError::JsonError(_) => ErrorCategory::Serialization,
ZaiError::RealtimeError(kind) => match kind.as_ref() {
RealtimeErrorKind::Protocol(_) => ErrorCategory::Client,
#[cfg(feature = "realtime")]
RealtimeErrorKind::WebSocket { .. } => ErrorCategory::Network,
RealtimeErrorKind::Timeout { .. } => ErrorCategory::Network,
RealtimeErrorKind::Closed => ErrorCategory::Other,
},
ZaiError::HttpError { status, .. } => classify_status(*status),
ZaiError::Unknown { code, .. } => {
if (500..600).contains(code) {
ErrorCategory::Server
} else {
ErrorCategory::Other
}
},
}
}
pub fn is_client_error(&self) -> bool {
matches!(
self.category(),
ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
)
}
pub fn is_server_error(&self) -> bool {
matches!(self.category(), ErrorCategory::Server)
}
pub fn is_retryable(&self) -> bool {
match self {
ZaiError::HttpError { status, .. } => {
crate::client::transport::retry::RETRYABLE_STATUSES.contains(status)
},
ZaiError::RateLimitError { code, .. } => matches!(*code, 429 | 1302 | 1305),
ZaiError::NetworkError(_) => true,
ZaiError::ApiError { code, .. } if *code == codes::SDK_TIMEOUT => true,
ZaiError::ApiError { code, .. } if is_server_business_code(*code) => true,
ZaiError::RealtimeError(kind)
if matches!(kind.as_ref(), RealtimeErrorKind::Timeout { .. }) =>
{
true
},
#[cfg(feature = "realtime")]
ZaiError::RealtimeError(kind)
if matches!(kind.as_ref(), RealtimeErrorKind::WebSocket { .. }) =>
{
true
},
_ => false,
}
}
pub fn is_sdk_error(&self) -> bool {
self.code().is_some_and(|c| (9000..=9999).contains(&c))
}
pub fn compact(&self) -> String {
match self {
ZaiError::HttpError { status, message } => {
format!("HTTP[{status}]: {message}")
},
ZaiError::AuthError { code, message } => {
format!("AUTH[{code}]: {message}")
},
ZaiError::AccountError { code, message } => {
format!("ACCOUNT[{code}]: {message}")
},
ZaiError::ApiError { code, message } => {
format!("API[{code}]: {message}")
},
ZaiError::RateLimitError { code, message } => {
format!("RATE_LIMIT[{code}]: {message}")
},
ZaiError::ContentPolicyError { code, message } => {
format!("POLICY[{code}]: {message}")
},
ZaiError::FileError { code, message } => {
format!("FILE[{code}]: {message}")
},
ZaiError::NetworkError(err) => {
format!("NETWORK: {err}")
},
ZaiError::JsonError(err) => {
format!("JSON: {err}")
},
ZaiError::RealtimeError(kind) => {
format!("REALTIME: {kind}")
},
ZaiError::RealtimeAuthError(msg) => {
format!("REALTIME_AUTH: {msg}")
},
ZaiError::Unknown { code, message } => {
format!("UNKNOWN[{code}]: {message}")
},
}
}
pub fn code(&self) -> Option<u16> {
match self {
ZaiError::HttpError { status, .. } => Some(*status),
ZaiError::AuthError { code, .. } => Some(*code),
ZaiError::AccountError { code, .. } => Some(*code),
ZaiError::ApiError { code, .. } => Some(*code),
ZaiError::RateLimitError { code, .. } => Some(*code),
ZaiError::ContentPolicyError { code, .. } => Some(*code),
ZaiError::FileError { code, .. } => Some(*code),
ZaiError::NetworkError(_) => None,
ZaiError::JsonError(_) => None,
ZaiError::RealtimeError(_) | ZaiError::RealtimeAuthError(_) => None,
ZaiError::Unknown { code, .. } => Some(*code),
}
}
pub fn message(&self) -> String {
match self {
ZaiError::HttpError { message, .. } => message.clone(),
ZaiError::AuthError { message, .. } => message.clone(),
ZaiError::AccountError { message, .. } => message.clone(),
ZaiError::ApiError { message, .. } => message.clone(),
ZaiError::RateLimitError { message, .. } => message.clone(),
ZaiError::ContentPolicyError { message, .. } => message.clone(),
ZaiError::FileError { message, .. } => message.clone(),
ZaiError::NetworkError(err) => err.to_string(),
ZaiError::JsonError(err) => err.to_string(),
ZaiError::RealtimeError(kind) => kind.to_string(),
ZaiError::RealtimeAuthError(msg) => msg.clone(),
ZaiError::Unknown { message, .. } => message.clone(),
}
}
pub fn context(self, context: &str) -> Self {
let with_context = |message: String| format!("{context}: {message}");
match self {
Self::HttpError { status, message } => Self::HttpError {
status,
message: with_context(message),
},
Self::AuthError { code, message } => Self::AuthError {
code,
message: with_context(message),
},
Self::AccountError { code, message } => Self::AccountError {
code,
message: with_context(message),
},
Self::ApiError { code, message } => Self::ApiError {
code,
message: with_context(message),
},
Self::RateLimitError { code, message } => Self::RateLimitError {
code,
message: with_context(message),
},
Self::ContentPolicyError { code, message } => Self::ContentPolicyError {
code,
message: with_context(message),
},
Self::FileError { code, message } => Self::FileError {
code,
message: with_context(message),
},
Self::NetworkError(err) => Self::NetworkError(err),
Self::JsonError(err) => Self::JsonError(err),
Self::RealtimeError(kind) => Self::RealtimeError(kind),
Self::RealtimeAuthError(message) => Self::RealtimeAuthError(with_context(message)),
Self::Unknown { code, message } => Self::Unknown {
code,
message: with_context(message),
},
}
}
}
const fn is_server_business_code(code: u16) -> bool {
matches!(code, 1200 | 1230 | 1234)
}
fn message_or(message: String, fallback: &str) -> String {
if message.is_empty() {
fallback.to_string()
} else {
message
}
}
pub type ZaiResult<T> = Result<T, ZaiError>;
impl From<reqwest::Error> for ZaiError {
fn from(err: reqwest::Error) -> Self {
let err = err.without_url();
if let Some(status) = err.status() {
ZaiError::from_api_response(status.as_u16(), 0, err.to_string())
} else {
ZaiError::NetworkError(Arc::new(err))
}
}
}
impl From<serde_json::Error> for ZaiError {
fn from(err: serde_json::Error) -> Self {
ZaiError::JsonError(Arc::new(err))
}
}
impl From<validator::ValidationErrors> for ZaiError {
fn from(err: validator::ValidationErrors) -> Self {
ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: sanitized_validation_message(&err),
}
}
}
fn sanitized_validation_message(errors: &validator::ValidationErrors) -> String {
fn collect(errors: &validator::ValidationErrors, prefix: &str, output: &mut Vec<String>) {
use validator::ValidationErrorsKind;
for (field, kind) in errors.errors() {
let path = if prefix.is_empty() {
field.to_string()
} else {
format!("{prefix}.{field}")
};
match kind {
ValidationErrorsKind::Field(field_errors) => {
for error in field_errors {
let mut constraints = error
.params
.keys()
.filter(|name| name.as_ref() != "value")
.map(ToString::to_string)
.collect::<Vec<_>>();
constraints.sort_unstable();
if constraints.is_empty() {
output.push(format!("{path}:{}", error.code));
} else {
output.push(format!(
"{path}:{} ({})",
error.code,
constraints.join(",")
));
}
}
},
ValidationErrorsKind::Struct(nested) => collect(nested, &path, output),
ValidationErrorsKind::List(items) => {
for (index, nested) in items {
collect(nested, &format!("{path}[{index}]"), output);
}
},
}
}
}
let mut issues = Vec::new();
collect(errors, "", &mut issues);
issues.sort_unstable();
if issues.is_empty() {
"validation failed".to_owned()
} else {
format!("validation failed: {}", issues.join("; "))
}
}
impl From<std::io::Error> for ZaiError {
fn from(err: std::io::Error) -> Self {
use std::io::ErrorKind;
match err.kind() {
ErrorKind::NotFound => ZaiError::FileError {
code: codes::SDK_FILE_NOT_FOUND,
message: err.to_string(),
},
ErrorKind::PermissionDenied => ZaiError::FileError {
code: codes::SDK_IO,
message: err.to_string(),
},
ErrorKind::TimedOut => ZaiError::ApiError {
code: codes::SDK_TIMEOUT,
message: err.to_string(),
},
_ => ZaiError::Unknown {
code: codes::SDK_IO,
message: err.to_string(),
},
}
}
}
impl From<RealtimeErrorKind> for ZaiError {
fn from(kind: RealtimeErrorKind) -> Self {
ZaiError::RealtimeError(Arc::new(kind))
}
}
#[cfg(feature = "realtime")]
impl From<tokio_tungstenite::tungstenite::Error> for ZaiError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
ZaiError::RealtimeError(Arc::new(RealtimeErrorKind::WebSocket { source: err }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Error, ErrorKind};
use validator::Validate;
#[derive(Validate)]
struct SensitiveValidationInput {
#[validate(length(min = 64))]
prompt: String,
}
#[test]
fn validation_conversion_never_includes_the_rejected_value() {
let sensitive = "private customer prompt";
let errors = SensitiveValidationInput {
prompt: sensitive.to_owned(),
}
.validate()
.expect_err("the short prompt must fail the test validator");
let error = ZaiError::from(errors);
let rendered = error.to_string();
assert!(rendered.contains("prompt:length"));
assert!(rendered.contains("min"));
assert!(!rendered.contains(sensitive));
}
#[test]
fn test_from_api_response_bad_request() {
let err = ZaiError::from_api_response(400, 0, "Invalid input".to_string());
assert!(err.is_client_error());
assert!(!err.is_server_error());
assert_eq!(err.code(), Some(400));
}
#[test]
fn api_response_messages_are_credential_redacted() {
let error = ZaiError::from_api_response(
400,
1210,
"authorization: Bearer abc123.abcdefghijklmnopqrstuvwxyz".to_owned(),
);
let rendered = error.to_string();
assert!(!rendered.contains("abcdefghijklmnopqrstuvwxyz"));
assert!(rendered.contains("[AUTH_REDACTED]"));
}
#[test]
fn test_from_api_response_unauthorized() {
let err = ZaiError::from_api_response(401, 0, "".to_string());
assert!(err.is_client_error());
assert_eq!(err.message(), "Unauthorized - check your API key");
}
#[test]
fn test_from_api_response_rate_limit() {
let err = ZaiError::from_api_response(429, 1302, "Too many requests".to_string());
assert!(err.is_client_error());
assert!(err.is_rate_limit());
assert_eq!(err.code(), Some(1302));
let err = ZaiError::from_api_response(200, 1302, "Too many requests".to_string());
assert!(err.is_client_error());
assert!(err.is_rate_limit());
assert_eq!(err.code(), Some(1302));
}
#[test]
fn test_from_api_response_package_limit_codes() {
for code in [
1113, 1302, 1305, 1308, 1309, 1310, 1311, 1313, 1314, 1315, 1316, 1317, 1318, 1319,
1320, 1321,
] {
let err = ZaiError::from_api_response(429, code, "Limited".to_string());
assert!(err.is_rate_limit());
assert_eq!(err.code(), Some(code));
}
for code in [1113, 1308, 1314, 1321] {
let err = ZaiError::from_api_response(429, code, "Limited".to_string());
assert!(!err.is_retryable(), "quota code {code} must not retry");
}
}
#[test]
fn test_from_api_response_content_policy_codes() {
let err = ZaiError::from_api_response(400, 1301, "Blocked".to_string());
assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
assert!(err.is_client_error());
assert!(!err.is_rate_limit());
assert_eq!(err.code(), Some(1301));
}
#[test]
fn context_window_code_is_a_non_retryable_client_validation_error() {
let error =
ZaiError::from_api_response(400, 1261, "model context window exceeded".to_owned());
assert!(matches!(error, ZaiError::ApiError { code: 1261, .. }));
assert_eq!(error.category(), ErrorCategory::Client);
assert!(!error.is_retryable());
}
#[test]
fn test_from_api_response_server_error() {
let err = ZaiError::from_api_response(500, 0, "".to_string());
assert!(!err.is_client_error());
assert!(err.is_server_error());
}
#[test]
fn test_from_api_response_auth_error_code() {
for code in [1001, 1005, 1220] {
let err = ZaiError::from_api_response(200, code, "Invalid API key".to_string());
assert!(err.is_auth_error());
assert_eq!(err.code(), Some(code));
assert_eq!(err.message(), "Invalid API key");
}
}
#[test]
fn test_from_api_response_account_error() {
let err = ZaiError::from_api_response(200, 1110, "Account expired".to_string());
assert!(err.is_client_error());
assert_eq!(err.code(), Some(1110));
}
#[test]
fn test_from_api_response_api_error() {
let err = ZaiError::from_api_response(200, 1210, "Invalid parameters".to_string());
assert!(err.is_client_error());
assert_eq!(err.code(), Some(1210));
}
#[test]
fn server_business_codes_preserve_code_and_retryability() {
for code in [1200, 1230, 1234] {
let err = ZaiError::from_api_response(200, code, "upstream failed".to_string());
assert!(matches!(err, ZaiError::ApiError { .. }));
assert_eq!(err.code(), Some(code));
assert_eq!(err.category(), ErrorCategory::Server);
assert!(err.is_server_error());
assert!(err.is_retryable());
assert!(!err.is_client_error());
}
}
#[test]
fn test_from_api_response_unknown_code() {
let err = ZaiError::from_api_response(200, 9999, "Unknown error".to_string());
assert!(!err.is_client_error()); assert_eq!(err.code(), Some(9999));
}
#[test]
fn test_compact() {
let err = ZaiError::HttpError {
status: 404,
message: "Not found".to_string(),
};
assert_eq!(err.compact(), "HTTP[404]: Not found");
let err = ZaiError::AuthError {
code: 1001,
message: "Invalid key".to_string(),
};
assert_eq!(err.compact(), "AUTH[1001]: Invalid key");
}
#[test]
fn test_code() {
let io_err = Error::new(ErrorKind::ConnectionRefused, "connection refused");
let err = ZaiError::from(io_err);
assert_eq!(err.code(), Some(codes::SDK_IO));
let err = ZaiError::JsonError(Arc::new(serde_json::Error::io(Error::new(
ErrorKind::InvalidData,
"invalid JSON",
))));
assert!(err.code().is_none());
let err = ZaiError::HttpError {
status: 500,
message: "Server error".to_string(),
};
assert_eq!(err.code(), Some(500));
}
#[test]
fn test_message() {
let err = ZaiError::RateLimitError {
code: 1302,
message: "Too many requests".to_string(),
};
assert_eq!(err.message(), "Too many requests");
}
#[test]
fn test_from_reqwest_error_with_status() {
let io_err = Error::other("test error");
let zai_err = ZaiError::from(io_err);
match zai_err {
ZaiError::Unknown { .. } => {},
_ => panic!("Expected Unknown error for io::Error"),
}
}
#[test]
fn test_sdk_code_constants_in_reserved_range() {
for code in [
codes::SDK_VALIDATION,
codes::SDK_CONFIG,
codes::SDK_FILE_NOT_FOUND,
codes::SDK_FILE_TOO_LARGE,
codes::SDK_FILE_TYPE_UNSUPPORTED,
codes::SDK_IO,
codes::SDK_TIMEOUT,
codes::SDK_EXTERNAL_TOOL,
] {
assert!(
(9000..=9999).contains(&code),
"code {code} outside 9000-9999"
);
}
}
#[test]
fn test_is_sdk_error_classification() {
assert!(
ZaiError::FileError {
code: codes::SDK_FILE_NOT_FOUND,
message: "x".into(),
}
.is_sdk_error()
);
assert!(
ZaiError::ApiError {
code: codes::SDK_TIMEOUT,
message: "x".into(),
}
.is_sdk_error()
);
assert!(
!ZaiError::AuthError {
code: 1001,
message: "x".into(),
}
.is_sdk_error()
);
assert!(
!ZaiError::RateLimitError {
code: 1302,
message: "x".into(),
}
.is_sdk_error()
);
assert!(
!ZaiError::HttpError {
status: 500,
message: "x".into(),
}
.is_sdk_error()
);
assert!(!ZaiError::RealtimeAuthError("x".into()).is_sdk_error());
}
#[test]
fn test_from_io_maps_by_kind() {
use std::io::{Error, ErrorKind};
let err = ZaiError::from(Error::from(ErrorKind::NotFound));
assert!(matches!(
err,
ZaiError::FileError { code, .. } if code == codes::SDK_FILE_NOT_FOUND
));
let err = ZaiError::from(Error::from(ErrorKind::TimedOut));
assert!(matches!(
err,
ZaiError::ApiError { code, .. } if code == codes::SDK_TIMEOUT
));
let err = ZaiError::from(Error::from(ErrorKind::PermissionDenied));
assert!(matches!(
err,
ZaiError::FileError { code, .. } if code == codes::SDK_IO
));
let err = ZaiError::from(Error::other("boom"));
assert!(matches!(
err,
ZaiError::Unknown { code, .. } if code == codes::SDK_IO
));
}
#[test]
fn test_context_preserves_code_and_variant() {
let err = ZaiError::ApiError {
code: 1200,
message: "bad model".into(),
}
.context("file parser create");
assert!(matches!(
err,
ZaiError::ApiError { code, .. } if code == 1200
));
assert_eq!(err.message(), "file parser create: bad model");
let err = ZaiError::Unknown {
code: codes::SDK_IO,
message: "boom".into(),
}
.context("read");
assert_eq!(err.code(), Some(codes::SDK_IO));
assert_eq!(err.message(), "read: boom");
}
#[test]
fn test_sdk_timeout_is_not_rate_limit() {
let err = ZaiError::ApiError {
code: codes::SDK_TIMEOUT,
message: "Timeout waiting for parsing result".into(),
};
assert!(!err.is_rate_limit());
assert!(err.is_sdk_error());
assert_eq!(err.category(), ErrorCategory::Network);
assert!(err.is_retryable());
assert!(!err.is_client_error());
}
#[test]
fn test_validate_api_key_valid() {
assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
}
#[test]
fn test_validate_api_key_empty() {
let result = validate_api_key("");
assert!(result.is_err());
match result {
Err(ZaiError::ApiError { code, .. }) => {
assert_eq!(code, codes::SDK_VALIDATION);
},
_ => panic!("Expected ApiError"),
}
}
#[test]
fn test_validate_api_key_no_dot() {
let result = validate_api_key("invalid");
assert!(result.is_err());
match result {
Err(ZaiError::ApiError { code, message }) => {
assert_eq!(code, codes::SDK_VALIDATION);
assert!(message.contains("format"));
},
_ => panic!("Expected ApiError"),
}
}
#[test]
fn test_validate_api_key_multiple_dots() {
let result = validate_api_key("id.secret.extra");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
}
#[test]
fn test_validate_api_key_empty_id() {
let result = validate_api_key(".secret123456789");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
}
#[test]
fn test_validate_api_key_empty_secret() {
let result = validate_api_key("id123.");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
}
#[test]
fn test_validate_api_key_invalid_chars() {
let result = validate_api_key("id$123.secret@456");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
}
#[test]
fn test_validate_api_key_id_too_short() {
let result = validate_api_key("ab.abcdefghijklmn");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("id is too short"));
}
#[test]
fn test_validate_api_key_secret_too_short() {
let result = validate_api_key("id123.short");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.message()
.contains("secret is too short")
);
}
#[test]
fn test_mask_sensitive_info_api_key() {
let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
let filtered = mask_sensitive_info(text);
assert!(filtered.contains("[FILTERED]"));
assert!(!filtered.contains("abc123"));
assert!(!filtered.contains("abcdefghijklmnopqrstuvwxyz"));
}
#[test]
fn test_mask_sensitive_info_password() {
let text = "password: secret123, other text";
let filtered = mask_sensitive_info(text);
assert!(filtered.contains("[FILTERED]"));
assert!(!filtered.contains("secret123"));
}
#[test]
fn test_mask_sensitive_info_token() {
let text = "token=abc123xyz, other content";
let filtered = mask_sensitive_info(text);
assert!(filtered.contains("[FILTERED]"));
assert!(!filtered.contains("abc123xyz"));
}
#[test]
fn test_mask_sensitive_info_bearer() {
let text = "Authorization: Bearer abc123.abc1234567890";
let filtered = mask_sensitive_info(text);
assert!(filtered.contains("[AUTH_REDACTED]"));
assert!(!filtered.contains("abc123"));
assert!(!filtered.contains("Bearer"));
assert!(!filtered.contains("Authorization"));
}
#[test]
fn test_mask_standalone_bearer_jwt_and_opaque_token() {
let jwt = "Bearer header.payload.signature";
let opaque = "bearer opaque-token-value";
assert_eq!(mask_sensitive_info(jwt), "Bearer [FILTERED]");
assert_eq!(mask_sensitive_info(opaque), "bearer [FILTERED]");
assert!(contains_sensitive_info(jwt));
assert!(contains_sensitive_info(opaque));
}
#[test]
fn test_mask_sensitive_info_multiple() {
let text = "api_key=abc123.xyz456, password=secret123";
let filtered = mask_sensitive_info(text);
let filtered_count = filtered.matches("[FILTERED]").count();
assert_eq!(filtered_count, 2);
}
#[test]
fn test_mask_sensitive_info_no_sensitive() {
let text = "Regular text without sensitive information";
let filtered = mask_sensitive_info(text);
assert_eq!(filtered, text);
}
#[test]
fn test_mask_api_key() {
let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
let filtered = mask_api_key(text);
assert!(filtered.contains("[FILTERED]"));
assert!(!filtered.contains("abc123"));
}
#[test]
fn test_contains_sensitive_info_api_key() {
assert!(contains_sensitive_info("api_key: abc123.abc1234567890"));
assert!(contains_sensitive_info("-id.secret-value-"));
assert!(!contains_sensitive_info("regular text"));
}
#[test]
fn api_key_masking_handles_hyphen_boundaries_and_multiple_values() {
let filtered = mask_sensitive_info("keys=-id.secret-value-,-next.another-secret-value-");
assert_eq!(filtered.matches("[FILTERED]").count(), 2);
assert!(!filtered.contains("secret-value"));
assert!(!contains_sensitive_info(&filtered));
}
#[test]
fn test_contains_sensitive_info_password() {
assert!(contains_sensitive_info("password: secret"));
assert!(contains_sensitive_info("password=123"));
assert!(!contains_sensitive_info("password"));
assert!(!contains_sensitive_info("word:password"));
}
#[test]
fn test_contains_sensitive_info_token() {
assert!(contains_sensitive_info("token=abc123"));
assert!(contains_sensitive_info("token: xyz123"));
assert!(!contains_sensitive_info("token"));
assert!(!contains_sensitive_info("tokenize this"));
}
#[test]
fn test_error_category_classification() {
let rl = ZaiError::RateLimitError {
code: 1302,
message: "slow down".into(),
};
assert_eq!(rl.category(), ErrorCategory::RateLimit);
assert!(rl.is_retryable());
assert!(rl.is_client_error());
assert!(!rl.is_server_error());
let h429 = ZaiError::HttpError {
status: 429,
message: "too many".into(),
};
assert_eq!(h429.category(), ErrorCategory::RateLimit);
assert!(h429.is_retryable());
assert!(h429.is_client_error());
let h500 = ZaiError::HttpError {
status: 500,
message: "boom".into(),
};
assert_eq!(h500.category(), ErrorCategory::Server);
assert!(h500.is_retryable());
assert!(h500.is_server_error());
assert!(!h500.is_client_error());
let h400 = ZaiError::HttpError {
status: 400,
message: "bad".into(),
};
assert_eq!(h400.category(), ErrorCategory::Client);
assert!(!h400.is_retryable());
assert!(h400.is_client_error());
let auth = ZaiError::AuthError {
code: 1001,
message: "bad key".into(),
};
assert_eq!(auth.category(), ErrorCategory::Auth);
assert!(!auth.is_retryable());
assert!(auth.is_client_error());
let unk = ZaiError::Unknown {
code: 503,
message: "?".into(),
};
assert_eq!(unk.category(), ErrorCategory::Server);
assert!(unk.is_server_error());
assert!(!unk.is_retryable());
}
#[test]
fn test_business_code_band_boundaries() {
for code in [1002, 1004, 1100, 1300, 1303, 1304, 1306, 1307, 1312] {
let e = ZaiError::from_api_response(400, code, "gap".to_string());
assert!(
matches!(e, ZaiError::Unknown { .. }),
"code {code} -> Unknown"
);
assert!(!e.is_rate_limit());
}
let e = ZaiError::from_api_response(400, 1499, "file".to_string());
assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
let e = ZaiError::from_api_response(400, 1400, "file".to_string());
assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
for code in [1113, 1302, 1305, 1308, 1321] {
let e = ZaiError::from_api_response(429, code, "rl".to_string());
assert!(e.is_rate_limit(), "code {code} -> RateLimitError");
}
}
#[test]
fn status_502_503_504_classify_as_server_and_carry_status() {
for status in [502, 503, 504] {
let e = ZaiError::from_api_response(status, 0, String::new());
match &e {
ZaiError::HttpError {
status: s,
message: _,
} => {
assert_eq!(*s, status, "HTTP {status} lost its status code");
assert!(e.is_server_error(), "HTTP {status} not classified Server");
assert!(
e.is_retryable(),
"HTTP {status} should be retryable as a 5xx"
);
},
other => panic!("HTTP {status} classified as {other:?}, expected HttpError"),
}
}
let e = ZaiError::from_api_response(500, 0, String::new());
assert!(matches!(e, ZaiError::HttpError { status: 500, .. }));
assert!(e.is_server_error());
}
#[test]
fn retry_helper_uses_the_transport_status_matrix() {
for status in [408, 425, 429, 500, 502, 503, 504] {
let error = ZaiError::from_api_response(status, 0, String::new());
assert!(error.is_retryable(), "HTTP {status} should be retryable");
}
for status in [400, 501, 505] {
let error = ZaiError::from_api_response(status, 0, String::new());
assert!(!error.is_retryable(), "HTTP {status} must not retry");
}
for status in [501, 505] {
let error = ZaiError::from_api_response(status, 0, String::new());
assert_eq!(error.category(), ErrorCategory::Other);
assert!(!error.is_server_error());
}
}
#[test]
fn status_401_403_classify_as_auth() {
for status in [401, 403] {
let e = ZaiError::from_api_response(status, 0, String::new());
assert!(
e.is_auth_error(),
"HTTP {status} should classify as auth, got {e:?}"
);
assert!(
e.is_client_error(),
"HTTP {status} should be a client error"
);
assert!(
!e.is_retryable(),
"HTTP {status} (auth) should not be retryable"
);
}
}
#[test]
fn status_429_classifies_as_rate_limit() {
let e = ZaiError::from_api_response(429, 0, String::new());
assert!(
e.is_rate_limit(),
"HTTP 429 should classify as rate limit, got {e:?}"
);
assert!(e.is_retryable(), "HTTP 429 should be retryable");
}
}