use std::sync::{Arc, LazyLock};
use regex::Regex;
use thiserror::Error;
static API_KEY_PATTERN: LazyLock<Option<Regex>> =
LazyLock::new(|| Regex::new(r"\b[a-zA-Z0-9_-]{3,}\.[a-zA-Z0-9_-]{10,}\b").ok());
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)(bearer\s+)[a-zA-Z0-9_-]+\.([a-zA-Z0-9_-]{10,})",
"$1[FILTERED]",
),
(
r"(?i)authorization\s*:\s*Bearer\s+[^\s,]+",
"[AUTH_REDACTED]",
),
]
.into_iter()
.filter_map(|(pat, repl)| Regex::new(pat).ok().map(|re| (re, repl)))
.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)authorization\s*:\s*Bearer",
]
.into_iter()
.filter_map(|pat| Regex::new(pat).ok())
.collect()
});
pub fn mask_sensitive_info(text: &str) -> String {
let mut result = match API_KEY_PATTERN.as_ref() {
Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
None => text.to_string(),
};
for (re, replacement) in SENSITIVE_PATTERNS.iter() {
result = re.replace_all(&result, *replacement).into_owned();
}
result
}
pub fn mask_api_key(text: &str) -> String {
match API_KEY_PATTERN.as_ref() {
Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
None => text.to_string(),
}
}
pub fn contains_sensitive_info(text: &str) -> bool {
if API_KEY_PATTERN.as_ref().is_some_and(|re| re.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 parts: Vec<&str> = api_key.split('.').collect();
if parts.len() != 2 {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "API key must be in format '<id>.<secret>'".to_string(),
});
}
let (id, secret) = (parts[0], parts[1]);
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 = |s: &str| -> bool {
s.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
};
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) => 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("serialize: {source}")]
Serialize {
#[source]
source: serde_json::Error,
},
#[error("protocol: {0}")]
Protocol(String),
#[error("server error event [code={code:?}]: {message}")]
ServerEvent {
code: String,
message: String,
},
#[error("session closed")]
Closed,
}
impl ZaiError {
pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
if api_code != 0 {
return match api_code {
1000..=1004 | 1100 => ZaiError::AuthError {
code: api_code,
message: api_message,
},
1110..=1121 => ZaiError::AccountError {
code: api_code,
message: api_message,
},
1200..=1234 => ZaiError::ApiError {
code: api_code,
message: api_message,
},
1300..=1301 => ZaiError::ContentPolicyError {
code: api_code,
message: api_message,
},
1302..=1305 | 1308..=1313 => ZaiError::RateLimitError {
code: api_code,
message: api_message,
},
1400..=1499 => ZaiError::FileError {
code: api_code,
message: api_message,
},
_ => ZaiError::Unknown {
code: api_code,
message: if api_message.is_empty() {
"Unknown error".to_string()
} else {
api_message
},
},
};
}
match status {
400 => ZaiError::HttpError {
status,
message: if api_message.is_empty() {
"Bad request - check your parameters".to_string()
} else {
api_message
},
},
401 | 403 => ZaiError::AuthError {
code: status,
message: if api_message.is_empty() {
"Unauthorized - check your API key".to_string()
} else {
api_message
},
},
404 => ZaiError::HttpError {
status,
message: "Not found - requested resource doesn't exist".to_string(),
},
429 => ZaiError::RateLimitError {
code: status,
message: if api_message.is_empty() {
"Too many requests - rate limit exceeded".to_string()
} else {
api_message
},
},
434 => ZaiError::HttpError {
status,
message: "No API permission - feature not available".to_string(),
},
435 => ZaiError::HttpError {
status,
message: "File size exceeds 100MB limit".to_string(),
},
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
},
},
_ => ZaiError::Unknown {
code: status,
message: if api_message.is_empty() {
"Unknown error".to_string()
} else {
api_message
},
},
}
}
pub fn is_rate_limit(&self) -> bool {
matches!(self, ZaiError::RateLimitError { .. })
}
pub fn is_auth_error(&self) -> bool {
matches!(self, ZaiError::AuthError { .. })
}
pub fn category(&self) -> ErrorCategory {
match self {
ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
ZaiError::NetworkError(_) => ErrorCategory::Network,
ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
ZaiError::AccountError { .. }
| ZaiError::ApiError { .. }
| ZaiError::ContentPolicyError { .. }
| ZaiError::FileError { .. } => ErrorCategory::Client,
ZaiError::JsonError(_) => ErrorCategory::Serialization,
ZaiError::RealtimeError(kind) => match kind.as_ref() {
RealtimeErrorKind::Protocol(_)
| RealtimeErrorKind::Serialize { .. }
| RealtimeErrorKind::ServerEvent { .. } => ErrorCategory::Client,
#[cfg(feature = "realtime")]
RealtimeErrorKind::WebSocket { .. } => 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, .. } => *status == 429 || (500..600).contains(status),
ZaiError::RateLimitError { .. } => true,
ZaiError::NetworkError(_) => 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),
},
}
}
}
pub type ZaiResult<T> = Result<T, ZaiError>;
impl From<reqwest::Error> for ZaiError {
fn from(err: reqwest::Error) -> Self {
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: format!("Validation error: {err:?}"),
}
}
}
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};
#[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 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 [1302, 1303, 1304, 1305, 1308, 1309, 1310, 1311, 1312, 1313] {
let err = ZaiError::from_api_response(429, code, "Limited".to_string());
assert!(err.is_rate_limit());
assert_eq!(err.code(), Some(code));
}
}
#[test]
fn test_from_api_response_content_policy_codes() {
for code in [1300, 1301] {
let err = ZaiError::from_api_response(400, code, "Blocked".to_string());
assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
assert!(err.is_client_error());
assert!(!err.is_rate_limit());
assert_eq!(err.code(), Some(code));
}
}
#[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() {
let err = ZaiError::from_api_response(200, 1001, "Invalid API key".to_string());
assert!(err.is_auth_error());
assert_eq!(err.code(), Some(1001));
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, 1200, "Invalid parameters".to_string());
assert!(err.is_client_error());
assert_eq!(err.code(), Some(1200));
}
#[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());
}
#[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_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("regular text"));
}
#[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 [1306, 1307] {
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 [1302, 1305, 1308, 1313] {
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 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");
}
}