1use std::sync::{Arc, LazyLock};
51
52use regex::Regex;
53use thiserror::Error;
54
55fn built_in_regex(pattern: &str) -> Regex {
58 Regex::new(pattern)
59 .unwrap_or_else(|error| panic!("invalid built-in regex {pattern:?}: {error}"))
60}
61
62static API_KEY_PATTERN: LazyLock<Regex> =
63 LazyLock::new(|| built_in_regex(r"[a-zA-Z0-9_-]{3,}\.[a-zA-Z0-9_-]{10,}"));
64
65static SENSITIVE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
66 [
67 (r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
68 (r"(?i)(password\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
69 (r"(?i)(token\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
70 (r"(?i)(secret\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
71 (r"(?i)(\bbearer\s+)[^\s,]+", "$1[FILTERED]"),
72 (
73 r"(?i)authorization\s*:\s*Bearer\s+[^\s,]+",
76 "[AUTH_REDACTED]",
77 ),
78 ]
79 .into_iter()
80 .map(|(pattern, replacement)| (built_in_regex(pattern), replacement))
81 .collect()
82});
83
84static CONTAINS_SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
85 [
86 r"(?i)api[_-]?key\s*[=:]",
87 r"(?i)password\s*[=:]",
88 r"(?i)token\s*[=:]",
89 r"(?i)secret\s*[=:]",
90 r"(?i)\bbearer\s+[^\s,]+",
91 r"(?i)authorization\s*:\s*Bearer",
92 ]
93 .into_iter()
94 .map(built_in_regex)
95 .collect()
96});
97
98pub fn mask_sensitive_info(text: &str) -> String {
124 let mut result = API_KEY_PATTERN.replace_all(text, "[FILTERED]").into_owned();
125
126 for (re, replacement) in SENSITIVE_PATTERNS.iter() {
127 result = re.replace_all(&result, *replacement).into_owned();
128 }
129
130 result
131}
132
133pub fn mask_api_key(text: &str) -> String {
138 API_KEY_PATTERN.replace_all(text, "[FILTERED]").into_owned()
139}
140
141pub fn contains_sensitive_info(text: &str) -> bool {
143 if API_KEY_PATTERN.is_match(text) {
144 return true;
145 }
146
147 CONTAINS_SENSITIVE_PATTERNS
148 .iter()
149 .any(|re| re.is_match(text))
150}
151
152pub fn validate_api_key(api_key: &str) -> ZaiResult<()> {
168 if api_key.is_empty() {
169 return Err(ZaiError::ApiError {
170 code: codes::SDK_VALIDATION,
171 message: "API key cannot be empty".to_string(),
172 });
173 }
174
175 let Some((id, secret)) = api_key.split_once('.') else {
176 return Err(ZaiError::ApiError {
177 code: codes::SDK_VALIDATION,
178 message: "API key must be in format '<id>.<secret>'".to_string(),
179 });
180 };
181
182 if secret.contains('.') {
183 return Err(ZaiError::ApiError {
184 code: codes::SDK_VALIDATION,
185 message: "API key must contain exactly one dot".to_string(),
186 });
187 }
188
189 if id.is_empty() || secret.is_empty() {
190 return Err(ZaiError::ApiError {
191 code: codes::SDK_VALIDATION,
192 message: "API key id and secret must not be empty".to_string(),
193 });
194 }
195
196 let valid_chars = |part: &str| {
197 part.bytes()
198 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
199 };
200
201 if !valid_chars(id) || !valid_chars(secret) {
202 return Err(ZaiError::ApiError {
203 code: codes::SDK_VALIDATION,
204 message: "API key contains invalid characters".to_string(),
205 });
206 }
207
208 if id.len() < 3 {
209 return Err(ZaiError::ApiError {
210 code: codes::SDK_VALIDATION,
211 message: "API key id is too short".to_string(),
212 });
213 }
214
215 if secret.len() < 10 {
216 return Err(ZaiError::ApiError {
217 code: codes::SDK_VALIDATION,
218 message: "API key secret is too short".to_string(),
219 });
220 }
221
222 Ok(())
223}
224
225pub mod codes {
234 pub const SDK_VALIDATION: u16 = 9001;
236
237 pub const SDK_CONFIG: u16 = 9600;
239
240 pub const SDK_FILE_NOT_FOUND: u16 = 9100;
242
243 pub const SDK_FILE_TOO_LARGE: u16 = 9101;
245
246 pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
248
249 pub const SDK_IO: u16 = 9400;
251
252 pub const SDK_TIMEOUT: u16 = 9300;
254
255 pub const SDK_EXTERNAL_TOOL: u16 = 9500;
257}
258
259#[derive(Error, Debug, Clone)]
261#[non_exhaustive]
262pub enum ZaiError {
263 #[error("HTTP error [{status}]: {message}")]
265 HttpError {
266 status: u16,
268 message: String,
270 },
271
272 #[error("Authentication error [{code}]: {message}")]
274 AuthError {
275 code: u16,
277 message: String,
279 },
280
281 #[error("Account error [{code}]: {message}")]
283 AccountError {
284 code: u16,
287 message: String,
289 },
290
291 #[error("API error [{code}]: {message}")]
293 ApiError {
294 code: u16,
298 message: String,
300 },
301
302 #[error("Rate limit error [{code}]: {message}")]
304 RateLimitError {
305 code: u16,
307 message: String,
309 },
310
311 #[error("Content policy error [{code}]: {message}")]
313 ContentPolicyError {
314 code: u16,
317 message: String,
319 },
320
321 #[error("File error [{code}]: {message}")]
323 FileError {
324 code: u16,
327 message: String,
329 },
330
331 #[error("Network error: {0}")]
334 NetworkError(#[source] Arc<reqwest::Error>),
335
336 #[error("JSON error: {0}")]
339 JsonError(#[source] Arc<serde_json::Error>),
340
341 #[error("Realtime error: {0}")]
345 RealtimeError(#[source] Arc<RealtimeErrorKind>),
346
347 #[error("Realtime auth error: {0}")]
350 RealtimeAuthError(String),
351
352 #[error("Unknown error [{code}]: {message}")]
354 Unknown {
355 code: u16,
358 message: String,
360 },
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370#[non_exhaustive]
371pub enum ErrorCategory {
372 Client,
374 Server,
376 RateLimit,
379 Network,
381 Auth,
383 Serialization,
385 Other,
387}
388
389fn classify_status(status: u16) -> ErrorCategory {
391 match status {
392 429 => ErrorCategory::RateLimit,
393 s if (400..500).contains(&s) => ErrorCategory::Client,
394 s if (500..600).contains(&s) && !matches!(s, 501 | 505) => ErrorCategory::Server,
395 _ => ErrorCategory::Other,
396 }
397}
398
399#[derive(Debug, thiserror::Error)]
405#[non_exhaustive]
406pub enum RealtimeErrorKind {
407 #[cfg(feature = "realtime")]
411 #[error("websocket: {source}")]
412 WebSocket {
413 #[source]
415 source: tokio_tungstenite::tungstenite::Error,
416 },
417
418 #[error("protocol: {0}")]
420 Protocol(String),
421
422 #[error("{operation} timed out")]
424 Timeout {
425 operation: &'static str,
427 },
428
429 #[error("session closed")]
431 Closed,
432}
433
434impl ZaiError {
435 pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
437 let api_message = mask_sensitive_info(&api_message);
441 if api_code != 0 {
442 return match api_code {
443 1000 | 1001 | 1003 | 1005 | 1220 => ZaiError::AuthError {
445 code: api_code,
446 message: api_message,
447 },
448 1113 => ZaiError::RateLimitError {
451 code: api_code,
452 message: api_message,
453 },
454 1110..=1121 => ZaiError::AccountError {
456 code: api_code,
457 message: api_message,
458 },
459 1200..=1234 | 1261 => ZaiError::ApiError {
463 code: api_code,
464 message: api_message,
465 },
466 1301 => ZaiError::ContentPolicyError {
468 code: api_code,
469 message: api_message,
470 },
471 1302 | 1305 | 1308..=1311 | 1313..=1321 => ZaiError::RateLimitError {
473 code: api_code,
474 message: api_message,
475 },
476 1400..=1499 => ZaiError::FileError {
478 code: api_code,
479 message: api_message,
480 },
481 _ => ZaiError::Unknown {
482 code: api_code,
483 message: message_or(api_message, "Unknown error"),
484 },
485 };
486 }
487
488 match status {
492 400 => ZaiError::HttpError {
493 status,
494 message: message_or(api_message, "Bad request - check your parameters"),
495 },
496 401 | 403 => ZaiError::AuthError {
497 code: status,
498 message: message_or(api_message, "Unauthorized - check your API key"),
499 },
500 404 => ZaiError::HttpError {
501 status,
502 message: message_or(api_message, "Not found - requested resource doesn't exist"),
503 },
504 429 => ZaiError::RateLimitError {
505 code: status,
506 message: message_or(api_message, "Too many requests - rate limit exceeded"),
507 },
508 434 => ZaiError::HttpError {
509 status,
510 message: message_or(api_message, "No API permission - feature not available"),
511 },
512 435 => ZaiError::HttpError {
513 status,
514 message: message_or(api_message, "File size exceeds 100MB limit"),
515 },
516 s if (500..600).contains(&s) => ZaiError::HttpError {
518 status,
519 message: if api_message.is_empty() {
520 format!("Server error (HTTP {status}) - try again later")
521 } else {
522 api_message
523 },
524 },
525 s if (400..500).contains(&s) => ZaiError::HttpError {
526 status,
527 message: message_or(api_message, "HTTP client error"),
528 },
529 _ => ZaiError::Unknown {
530 code: status,
531 message: message_or(api_message, "Unknown error"),
532 },
533 }
534 }
535
536 pub fn is_rate_limit(&self) -> bool {
538 self.category() == ErrorCategory::RateLimit
539 }
540
541 pub fn is_auth_error(&self) -> bool {
543 self.category() == ErrorCategory::Auth
544 }
545
546 pub fn category(&self) -> ErrorCategory {
554 match self {
555 ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
556 ZaiError::NetworkError(_) => ErrorCategory::Network,
557 ZaiError::ApiError { code, .. } if *code == codes::SDK_TIMEOUT => {
558 ErrorCategory::Network
559 },
560 ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
561 ZaiError::ApiError { code, .. } if is_server_business_code(*code) => {
562 ErrorCategory::Server
563 },
564 ZaiError::AccountError { .. }
565 | ZaiError::ApiError { .. }
566 | ZaiError::ContentPolicyError { .. }
567 | ZaiError::FileError { .. } => ErrorCategory::Client,
568 ZaiError::JsonError(_) => ErrorCategory::Serialization,
569 ZaiError::RealtimeError(kind) => match kind.as_ref() {
570 RealtimeErrorKind::Protocol(_) => ErrorCategory::Client,
573 #[cfg(feature = "realtime")]
574 RealtimeErrorKind::WebSocket { .. } => ErrorCategory::Network,
575 RealtimeErrorKind::Timeout { .. } => ErrorCategory::Network,
576 RealtimeErrorKind::Closed => ErrorCategory::Other,
577 },
578 ZaiError::HttpError { status, .. } => classify_status(*status),
579 ZaiError::Unknown { code, .. } => {
585 if (500..600).contains(code) {
586 ErrorCategory::Server
587 } else {
588 ErrorCategory::Other
589 }
590 },
591 }
592 }
593
594 pub fn is_client_error(&self) -> bool {
597 matches!(
598 self.category(),
599 ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
600 )
601 }
602
603 pub fn is_server_error(&self) -> bool {
605 matches!(self.category(), ErrorCategory::Server)
606 }
607
608 pub fn is_retryable(&self) -> bool {
617 match self {
618 ZaiError::HttpError { status, .. } => {
619 crate::client::transport::retry::RETRYABLE_STATUSES.contains(status)
620 },
621 ZaiError::RateLimitError { code, .. } => matches!(*code, 429 | 1302 | 1305),
622 ZaiError::NetworkError(_) => true,
623 ZaiError::ApiError { code, .. } if *code == codes::SDK_TIMEOUT => true,
624 ZaiError::ApiError { code, .. } if is_server_business_code(*code) => true,
625 ZaiError::RealtimeError(kind)
626 if matches!(kind.as_ref(), RealtimeErrorKind::Timeout { .. }) =>
627 {
628 true
629 },
630 #[cfg(feature = "realtime")]
631 ZaiError::RealtimeError(kind)
632 if matches!(kind.as_ref(), RealtimeErrorKind::WebSocket { .. }) =>
633 {
634 true
635 },
636 _ => false,
637 }
638 }
639
640 pub fn is_sdk_error(&self) -> bool {
647 self.code().is_some_and(|c| (9000..=9999).contains(&c))
648 }
649
650 pub fn compact(&self) -> String {
652 match self {
653 ZaiError::HttpError { status, message } => {
654 format!("HTTP[{status}]: {message}")
655 },
656 ZaiError::AuthError { code, message } => {
657 format!("AUTH[{code}]: {message}")
658 },
659 ZaiError::AccountError { code, message } => {
660 format!("ACCOUNT[{code}]: {message}")
661 },
662 ZaiError::ApiError { code, message } => {
663 format!("API[{code}]: {message}")
664 },
665 ZaiError::RateLimitError { code, message } => {
666 format!("RATE_LIMIT[{code}]: {message}")
667 },
668 ZaiError::ContentPolicyError { code, message } => {
669 format!("POLICY[{code}]: {message}")
670 },
671 ZaiError::FileError { code, message } => {
672 format!("FILE[{code}]: {message}")
673 },
674 ZaiError::NetworkError(err) => {
675 format!("NETWORK: {err}")
676 },
677 ZaiError::JsonError(err) => {
678 format!("JSON: {err}")
679 },
680 ZaiError::RealtimeError(kind) => {
681 format!("REALTIME: {kind}")
682 },
683 ZaiError::RealtimeAuthError(msg) => {
684 format!("REALTIME_AUTH: {msg}")
685 },
686 ZaiError::Unknown { code, message } => {
687 format!("UNKNOWN[{code}]: {message}")
688 },
689 }
690 }
691
692 pub fn code(&self) -> Option<u16> {
694 match self {
695 ZaiError::HttpError { status, .. } => Some(*status),
696 ZaiError::AuthError { code, .. } => Some(*code),
697 ZaiError::AccountError { code, .. } => Some(*code),
698 ZaiError::ApiError { code, .. } => Some(*code),
699 ZaiError::RateLimitError { code, .. } => Some(*code),
700 ZaiError::ContentPolicyError { code, .. } => Some(*code),
701 ZaiError::FileError { code, .. } => Some(*code),
702 ZaiError::NetworkError(_) => None,
703 ZaiError::JsonError(_) => None,
704 ZaiError::RealtimeError(_) | ZaiError::RealtimeAuthError(_) => None,
705 ZaiError::Unknown { code, .. } => Some(*code),
706 }
707 }
708
709 pub fn message(&self) -> String {
711 match self {
712 ZaiError::HttpError { message, .. } => message.clone(),
713 ZaiError::AuthError { message, .. } => message.clone(),
714 ZaiError::AccountError { message, .. } => message.clone(),
715 ZaiError::ApiError { message, .. } => message.clone(),
716 ZaiError::RateLimitError { message, .. } => message.clone(),
717 ZaiError::ContentPolicyError { message, .. } => message.clone(),
718 ZaiError::FileError { message, .. } => message.clone(),
719 ZaiError::NetworkError(err) => err.to_string(),
720 ZaiError::JsonError(err) => err.to_string(),
721 ZaiError::RealtimeError(kind) => kind.to_string(),
722 ZaiError::RealtimeAuthError(msg) => msg.clone(),
723 ZaiError::Unknown { message, .. } => message.clone(),
724 }
725 }
726
727 pub fn context(self, context: &str) -> Self {
751 let with_context = |message: String| format!("{context}: {message}");
752 match self {
753 Self::HttpError { status, message } => Self::HttpError {
754 status,
755 message: with_context(message),
756 },
757 Self::AuthError { code, message } => Self::AuthError {
758 code,
759 message: with_context(message),
760 },
761 Self::AccountError { code, message } => Self::AccountError {
762 code,
763 message: with_context(message),
764 },
765 Self::ApiError { code, message } => Self::ApiError {
766 code,
767 message: with_context(message),
768 },
769 Self::RateLimitError { code, message } => Self::RateLimitError {
770 code,
771 message: with_context(message),
772 },
773 Self::ContentPolicyError { code, message } => Self::ContentPolicyError {
774 code,
775 message: with_context(message),
776 },
777 Self::FileError { code, message } => Self::FileError {
778 code,
779 message: with_context(message),
780 },
781 Self::NetworkError(err) => Self::NetworkError(err),
784 Self::JsonError(err) => Self::JsonError(err),
785 Self::RealtimeError(kind) => Self::RealtimeError(kind),
786 Self::RealtimeAuthError(message) => Self::RealtimeAuthError(with_context(message)),
787 Self::Unknown { code, message } => Self::Unknown {
788 code,
789 message: with_context(message),
790 },
791 }
792 }
793}
794
795const fn is_server_business_code(code: u16) -> bool {
798 matches!(code, 1200 | 1230 | 1234)
799}
800
801fn message_or(message: String, fallback: &str) -> String {
802 if message.is_empty() {
803 fallback.to_string()
804 } else {
805 message
806 }
807}
808
809pub type ZaiResult<T> = Result<T, ZaiError>;
811
812impl From<reqwest::Error> for ZaiError {
814 fn from(err: reqwest::Error) -> Self {
815 let err = err.without_url();
819 if let Some(status) = err.status() {
820 ZaiError::from_api_response(status.as_u16(), 0, err.to_string())
821 } else {
822 ZaiError::NetworkError(Arc::new(err))
823 }
824 }
825}
826
827impl From<serde_json::Error> for ZaiError {
829 fn from(err: serde_json::Error) -> Self {
830 ZaiError::JsonError(Arc::new(err))
831 }
832}
833
834impl From<validator::ValidationErrors> for ZaiError {
836 fn from(err: validator::ValidationErrors) -> Self {
837 ZaiError::ApiError {
838 code: codes::SDK_VALIDATION,
839 message: sanitized_validation_message(&err),
840 }
841 }
842}
843
844fn sanitized_validation_message(errors: &validator::ValidationErrors) -> String {
845 fn collect(errors: &validator::ValidationErrors, prefix: &str, output: &mut Vec<String>) {
846 use validator::ValidationErrorsKind;
847
848 for (field, kind) in errors.errors() {
849 let path = if prefix.is_empty() {
850 field.to_string()
851 } else {
852 format!("{prefix}.{field}")
853 };
854 match kind {
855 ValidationErrorsKind::Field(field_errors) => {
856 for error in field_errors {
857 let mut constraints = error
858 .params
859 .keys()
860 .filter(|name| name.as_ref() != "value")
861 .map(ToString::to_string)
862 .collect::<Vec<_>>();
863 constraints.sort_unstable();
864 if constraints.is_empty() {
865 output.push(format!("{path}:{}", error.code));
866 } else {
867 output.push(format!(
868 "{path}:{} ({})",
869 error.code,
870 constraints.join(",")
871 ));
872 }
873 }
874 },
875 ValidationErrorsKind::Struct(nested) => collect(nested, &path, output),
876 ValidationErrorsKind::List(items) => {
877 for (index, nested) in items {
878 collect(nested, &format!("{path}[{index}]"), output);
879 }
880 },
881 }
882 }
883 }
884
885 let mut issues = Vec::new();
886 collect(errors, "", &mut issues);
887 issues.sort_unstable();
888 if issues.is_empty() {
889 "validation failed".to_owned()
890 } else {
891 format!("validation failed: {}", issues.join("; "))
892 }
893}
894
895impl From<std::io::Error> for ZaiError {
903 fn from(err: std::io::Error) -> Self {
904 use std::io::ErrorKind;
905 match err.kind() {
906 ErrorKind::NotFound => ZaiError::FileError {
907 code: codes::SDK_FILE_NOT_FOUND,
908 message: err.to_string(),
909 },
910 ErrorKind::PermissionDenied => ZaiError::FileError {
911 code: codes::SDK_IO,
912 message: err.to_string(),
913 },
914 ErrorKind::TimedOut => ZaiError::ApiError {
915 code: codes::SDK_TIMEOUT,
916 message: err.to_string(),
917 },
918 _ => ZaiError::Unknown {
919 code: codes::SDK_IO,
920 message: err.to_string(),
921 },
922 }
923 }
924}
925
926impl From<RealtimeErrorKind> for ZaiError {
928 fn from(kind: RealtimeErrorKind) -> Self {
929 ZaiError::RealtimeError(Arc::new(kind))
930 }
931}
932
933#[cfg(feature = "realtime")]
938impl From<tokio_tungstenite::tungstenite::Error> for ZaiError {
939 fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
940 ZaiError::RealtimeError(Arc::new(RealtimeErrorKind::WebSocket { source: err }))
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use std::io::{Error, ErrorKind};
951 use validator::Validate;
952
953 #[derive(Validate)]
954 struct SensitiveValidationInput {
955 #[validate(length(min = 64))]
956 prompt: String,
957 }
958
959 #[test]
960 fn validation_conversion_never_includes_the_rejected_value() {
961 let sensitive = "private customer prompt";
962 let errors = SensitiveValidationInput {
963 prompt: sensitive.to_owned(),
964 }
965 .validate()
966 .expect_err("the short prompt must fail the test validator");
967 let error = ZaiError::from(errors);
968 let rendered = error.to_string();
969
970 assert!(rendered.contains("prompt:length"));
971 assert!(rendered.contains("min"));
972 assert!(!rendered.contains(sensitive));
973 }
974
975 #[test]
976 fn test_from_api_response_bad_request() {
977 let err = ZaiError::from_api_response(400, 0, "Invalid input".to_string());
978 assert!(err.is_client_error());
979 assert!(!err.is_server_error());
980 assert_eq!(err.code(), Some(400));
981 }
982
983 #[test]
984 fn api_response_messages_are_credential_redacted() {
985 let error = ZaiError::from_api_response(
986 400,
987 1210,
988 "authorization: Bearer abc123.abcdefghijklmnopqrstuvwxyz".to_owned(),
989 );
990 let rendered = error.to_string();
991 assert!(!rendered.contains("abcdefghijklmnopqrstuvwxyz"));
992 assert!(rendered.contains("[AUTH_REDACTED]"));
993 }
994
995 #[test]
996 fn test_from_api_response_unauthorized() {
997 let err = ZaiError::from_api_response(401, 0, "".to_string());
998 assert!(err.is_client_error());
999 assert_eq!(err.message(), "Unauthorized - check your API key");
1000 }
1001
1002 #[test]
1003 fn test_from_api_response_rate_limit() {
1004 let err = ZaiError::from_api_response(429, 1302, "Too many requests".to_string());
1006 assert!(err.is_client_error());
1007 assert!(err.is_rate_limit());
1008 assert_eq!(err.code(), Some(1302));
1009
1010 let err = ZaiError::from_api_response(200, 1302, "Too many requests".to_string());
1013 assert!(err.is_client_error());
1014 assert!(err.is_rate_limit());
1015 assert_eq!(err.code(), Some(1302));
1016 }
1017
1018 #[test]
1019 fn test_from_api_response_package_limit_codes() {
1020 for code in [
1021 1113, 1302, 1305, 1308, 1309, 1310, 1311, 1313, 1314, 1315, 1316, 1317, 1318, 1319,
1022 1320, 1321,
1023 ] {
1024 let err = ZaiError::from_api_response(429, code, "Limited".to_string());
1025 assert!(err.is_rate_limit());
1026 assert_eq!(err.code(), Some(code));
1027 }
1028
1029 for code in [1113, 1308, 1314, 1321] {
1030 let err = ZaiError::from_api_response(429, code, "Limited".to_string());
1031 assert!(!err.is_retryable(), "quota code {code} must not retry");
1032 }
1033 }
1034
1035 #[test]
1036 fn test_from_api_response_content_policy_codes() {
1037 let err = ZaiError::from_api_response(400, 1301, "Blocked".to_string());
1038 assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
1039 assert!(err.is_client_error());
1040 assert!(!err.is_rate_limit());
1041 assert_eq!(err.code(), Some(1301));
1042 }
1043
1044 #[test]
1045 fn context_window_code_is_a_non_retryable_client_validation_error() {
1046 let error =
1047 ZaiError::from_api_response(400, 1261, "model context window exceeded".to_owned());
1048 assert!(matches!(error, ZaiError::ApiError { code: 1261, .. }));
1049 assert_eq!(error.category(), ErrorCategory::Client);
1050 assert!(!error.is_retryable());
1051 }
1052
1053 #[test]
1054 fn test_from_api_response_server_error() {
1055 let err = ZaiError::from_api_response(500, 0, "".to_string());
1056 assert!(!err.is_client_error());
1057 assert!(err.is_server_error());
1058 }
1059
1060 #[test]
1061 fn test_from_api_response_auth_error_code() {
1062 for code in [1001, 1005, 1220] {
1063 let err = ZaiError::from_api_response(200, code, "Invalid API key".to_string());
1064 assert!(err.is_auth_error());
1065 assert_eq!(err.code(), Some(code));
1066 assert_eq!(err.message(), "Invalid API key");
1067 }
1068 }
1069
1070 #[test]
1071 fn test_from_api_response_account_error() {
1072 let err = ZaiError::from_api_response(200, 1110, "Account expired".to_string());
1073 assert!(err.is_client_error());
1074 assert_eq!(err.code(), Some(1110));
1075 }
1076
1077 #[test]
1078 fn test_from_api_response_api_error() {
1079 let err = ZaiError::from_api_response(200, 1210, "Invalid parameters".to_string());
1080 assert!(err.is_client_error());
1081 assert_eq!(err.code(), Some(1210));
1082 }
1083
1084 #[test]
1085 fn server_business_codes_preserve_code_and_retryability() {
1086 for code in [1200, 1230, 1234] {
1087 let err = ZaiError::from_api_response(200, code, "upstream failed".to_string());
1088 assert!(matches!(err, ZaiError::ApiError { .. }));
1089 assert_eq!(err.code(), Some(code));
1090 assert_eq!(err.category(), ErrorCategory::Server);
1091 assert!(err.is_server_error());
1092 assert!(err.is_retryable());
1093 assert!(!err.is_client_error());
1094 }
1095 }
1096
1097 #[test]
1098 fn test_from_api_response_unknown_code() {
1099 let err = ZaiError::from_api_response(200, 9999, "Unknown error".to_string());
1100 assert!(!err.is_client_error()); assert_eq!(err.code(), Some(9999));
1102 }
1103
1104 #[test]
1105 fn test_compact() {
1106 let err = ZaiError::HttpError {
1107 status: 404,
1108 message: "Not found".to_string(),
1109 };
1110 assert_eq!(err.compact(), "HTTP[404]: Not found");
1111
1112 let err = ZaiError::AuthError {
1113 code: 1001,
1114 message: "Invalid key".to_string(),
1115 };
1116 assert_eq!(err.compact(), "AUTH[1001]: Invalid key");
1117 }
1118
1119 #[test]
1120 fn test_code() {
1121 let io_err = Error::new(ErrorKind::ConnectionRefused, "connection refused");
1125 let err = ZaiError::from(io_err);
1126 assert_eq!(err.code(), Some(codes::SDK_IO));
1127
1128 let err = ZaiError::JsonError(Arc::new(serde_json::Error::io(Error::new(
1130 ErrorKind::InvalidData,
1131 "invalid JSON",
1132 ))));
1133 assert!(err.code().is_none());
1134
1135 let err = ZaiError::HttpError {
1137 status: 500,
1138 message: "Server error".to_string(),
1139 };
1140 assert_eq!(err.code(), Some(500));
1141 }
1142
1143 #[test]
1144 fn test_message() {
1145 let err = ZaiError::RateLimitError {
1146 code: 1302,
1147 message: "Too many requests".to_string(),
1148 };
1149 assert_eq!(err.message(), "Too many requests");
1150 }
1151
1152 #[test]
1153 fn test_from_reqwest_error_with_status() {
1154 let io_err = Error::other("test error");
1155 let zai_err = ZaiError::from(io_err);
1156 match zai_err {
1157 ZaiError::Unknown { .. } => {},
1158 _ => panic!("Expected Unknown error for io::Error"),
1159 }
1160 }
1161
1162 #[test]
1163 fn test_sdk_code_constants_in_reserved_range() {
1164 for code in [
1165 codes::SDK_VALIDATION,
1166 codes::SDK_CONFIG,
1167 codes::SDK_FILE_NOT_FOUND,
1168 codes::SDK_FILE_TOO_LARGE,
1169 codes::SDK_FILE_TYPE_UNSUPPORTED,
1170 codes::SDK_IO,
1171 codes::SDK_TIMEOUT,
1172 codes::SDK_EXTERNAL_TOOL,
1173 ] {
1174 assert!(
1175 (9000..=9999).contains(&code),
1176 "code {code} outside 9000-9999"
1177 );
1178 }
1179 }
1180
1181 #[test]
1182 fn test_is_sdk_error_classification() {
1183 assert!(
1185 ZaiError::FileError {
1186 code: codes::SDK_FILE_NOT_FOUND,
1187 message: "x".into(),
1188 }
1189 .is_sdk_error()
1190 );
1191 assert!(
1192 ZaiError::ApiError {
1193 code: codes::SDK_TIMEOUT,
1194 message: "x".into(),
1195 }
1196 .is_sdk_error()
1197 );
1198
1199 assert!(
1201 !ZaiError::AuthError {
1202 code: 1001,
1203 message: "x".into(),
1204 }
1205 .is_sdk_error()
1206 );
1207 assert!(
1208 !ZaiError::RateLimitError {
1209 code: 1302,
1210 message: "x".into(),
1211 }
1212 .is_sdk_error()
1213 );
1214 assert!(
1215 !ZaiError::HttpError {
1216 status: 500,
1217 message: "x".into(),
1218 }
1219 .is_sdk_error()
1220 );
1221
1222 assert!(!ZaiError::RealtimeAuthError("x".into()).is_sdk_error());
1224 }
1225
1226 #[test]
1227 fn test_from_io_maps_by_kind() {
1228 use std::io::{Error, ErrorKind};
1229
1230 let err = ZaiError::from(Error::from(ErrorKind::NotFound));
1231 assert!(matches!(
1232 err,
1233 ZaiError::FileError { code, .. } if code == codes::SDK_FILE_NOT_FOUND
1234 ));
1235
1236 let err = ZaiError::from(Error::from(ErrorKind::TimedOut));
1237 assert!(matches!(
1238 err,
1239 ZaiError::ApiError { code, .. } if code == codes::SDK_TIMEOUT
1240 ));
1241
1242 let err = ZaiError::from(Error::from(ErrorKind::PermissionDenied));
1243 assert!(matches!(
1244 err,
1245 ZaiError::FileError { code, .. } if code == codes::SDK_IO
1246 ));
1247
1248 let err = ZaiError::from(Error::other("boom"));
1250 assert!(matches!(
1251 err,
1252 ZaiError::Unknown { code, .. } if code == codes::SDK_IO
1253 ));
1254 }
1255
1256 #[test]
1257 fn test_context_preserves_code_and_variant() {
1258 let err = ZaiError::ApiError {
1259 code: 1200,
1260 message: "bad model".into(),
1261 }
1262 .context("file parser create");
1263 assert!(matches!(
1264 err,
1265 ZaiError::ApiError { code, .. } if code == 1200
1266 ));
1267 assert_eq!(err.message(), "file parser create: bad model");
1268
1269 let err = ZaiError::Unknown {
1270 code: codes::SDK_IO,
1271 message: "boom".into(),
1272 }
1273 .context("read");
1274 assert_eq!(err.code(), Some(codes::SDK_IO));
1275 assert_eq!(err.message(), "read: boom");
1276 }
1277
1278 #[test]
1279 fn test_sdk_timeout_is_not_rate_limit() {
1280 let err = ZaiError::ApiError {
1284 code: codes::SDK_TIMEOUT,
1285 message: "Timeout waiting for parsing result".into(),
1286 };
1287 assert!(!err.is_rate_limit());
1288 assert!(err.is_sdk_error());
1289 assert_eq!(err.category(), ErrorCategory::Network);
1290 assert!(err.is_retryable());
1291 assert!(!err.is_client_error());
1292 }
1293
1294 #[test]
1295 fn test_validate_api_key_valid() {
1296 assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
1297 }
1298
1299 #[test]
1300 fn test_validate_api_key_empty() {
1301 let result = validate_api_key("");
1302 assert!(result.is_err());
1303 match result {
1304 Err(ZaiError::ApiError { code, .. }) => {
1305 assert_eq!(code, codes::SDK_VALIDATION);
1306 },
1307 _ => panic!("Expected ApiError"),
1308 }
1309 }
1310
1311 #[test]
1312 fn test_validate_api_key_no_dot() {
1313 let result = validate_api_key("invalid");
1314 assert!(result.is_err());
1315 match result {
1316 Err(ZaiError::ApiError { code, message }) => {
1317 assert_eq!(code, codes::SDK_VALIDATION);
1318 assert!(message.contains("format"));
1319 },
1320 _ => panic!("Expected ApiError"),
1321 }
1322 }
1323
1324 #[test]
1325 fn test_validate_api_key_multiple_dots() {
1326 let result = validate_api_key("id.secret.extra");
1327 assert!(result.is_err());
1328 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1329 }
1330
1331 #[test]
1332 fn test_validate_api_key_empty_id() {
1333 let result = validate_api_key(".secret123456789");
1334 assert!(result.is_err());
1335 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1336 }
1337
1338 #[test]
1339 fn test_validate_api_key_empty_secret() {
1340 let result = validate_api_key("id123.");
1341 assert!(result.is_err());
1342 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1343 }
1344
1345 #[test]
1346 fn test_validate_api_key_invalid_chars() {
1347 let result = validate_api_key("id$123.secret@456");
1348 assert!(result.is_err());
1349 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1350 }
1351
1352 #[test]
1353 fn test_validate_api_key_id_too_short() {
1354 let result = validate_api_key("ab.abcdefghijklmn");
1355 assert!(result.is_err());
1356 assert!(result.unwrap_err().message().contains("id is too short"));
1357 }
1358
1359 #[test]
1360 fn test_validate_api_key_secret_too_short() {
1361 let result = validate_api_key("id123.short");
1362 assert!(result.is_err());
1363 assert!(
1364 result
1365 .unwrap_err()
1366 .message()
1367 .contains("secret is too short")
1368 );
1369 }
1370
1371 #[test]
1372 fn test_mask_sensitive_info_api_key() {
1373 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1374 let filtered = mask_sensitive_info(text);
1375 assert!(filtered.contains("[FILTERED]"));
1376 assert!(!filtered.contains("abc123"));
1377 assert!(!filtered.contains("abcdefghijklmnopqrstuvwxyz"));
1378 }
1379
1380 #[test]
1381 fn test_mask_sensitive_info_password() {
1382 let text = "password: secret123, other text";
1383 let filtered = mask_sensitive_info(text);
1384 assert!(filtered.contains("[FILTERED]"));
1385 assert!(!filtered.contains("secret123"));
1386 }
1387
1388 #[test]
1389 fn test_mask_sensitive_info_token() {
1390 let text = "token=abc123xyz, other content";
1391 let filtered = mask_sensitive_info(text);
1392 assert!(filtered.contains("[FILTERED]"));
1393 assert!(!filtered.contains("abc123xyz"));
1394 }
1395
1396 #[test]
1397 fn test_mask_sensitive_info_bearer() {
1398 let text = "Authorization: Bearer abc123.abc1234567890";
1399 let filtered = mask_sensitive_info(text);
1400 assert!(filtered.contains("[AUTH_REDACTED]"));
1403 assert!(!filtered.contains("abc123"));
1404 assert!(!filtered.contains("Bearer"));
1405 assert!(!filtered.contains("Authorization"));
1406 }
1407
1408 #[test]
1409 fn test_mask_standalone_bearer_jwt_and_opaque_token() {
1410 let jwt = "Bearer header.payload.signature";
1411 let opaque = "bearer opaque-token-value";
1412 assert_eq!(mask_sensitive_info(jwt), "Bearer [FILTERED]");
1413 assert_eq!(mask_sensitive_info(opaque), "bearer [FILTERED]");
1414 assert!(contains_sensitive_info(jwt));
1415 assert!(contains_sensitive_info(opaque));
1416 }
1417
1418 #[test]
1419 fn test_mask_sensitive_info_multiple() {
1420 let text = "api_key=abc123.xyz456, password=secret123";
1421 let filtered = mask_sensitive_info(text);
1422 let filtered_count = filtered.matches("[FILTERED]").count();
1423 assert_eq!(filtered_count, 2);
1424 }
1425
1426 #[test]
1427 fn test_mask_sensitive_info_no_sensitive() {
1428 let text = "Regular text without sensitive information";
1429 let filtered = mask_sensitive_info(text);
1430 assert_eq!(filtered, text);
1431 }
1432
1433 #[test]
1434 fn test_mask_api_key() {
1435 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1436 let filtered = mask_api_key(text);
1437 assert!(filtered.contains("[FILTERED]"));
1438 assert!(!filtered.contains("abc123"));
1439 }
1440
1441 #[test]
1442 fn test_contains_sensitive_info_api_key() {
1443 assert!(contains_sensitive_info("api_key: abc123.abc1234567890"));
1444 assert!(contains_sensitive_info("-id.secret-value-"));
1445 assert!(!contains_sensitive_info("regular text"));
1446 }
1447
1448 #[test]
1449 fn api_key_masking_handles_hyphen_boundaries_and_multiple_values() {
1450 let filtered = mask_sensitive_info("keys=-id.secret-value-,-next.another-secret-value-");
1451 assert_eq!(filtered.matches("[FILTERED]").count(), 2);
1452 assert!(!filtered.contains("secret-value"));
1453 assert!(!contains_sensitive_info(&filtered));
1454 }
1455
1456 #[test]
1457 fn test_contains_sensitive_info_password() {
1458 assert!(contains_sensitive_info("password: secret"));
1459 assert!(contains_sensitive_info("password=123"));
1460 assert!(!contains_sensitive_info("password"));
1461 assert!(!contains_sensitive_info("word:password"));
1462 }
1463
1464 #[test]
1465 fn test_contains_sensitive_info_token() {
1466 assert!(contains_sensitive_info("token=abc123"));
1467 assert!(contains_sensitive_info("token: xyz123"));
1468 assert!(!contains_sensitive_info("token"));
1469 assert!(!contains_sensitive_info("tokenize this"));
1470 }
1471
1472 #[test]
1473 fn test_error_category_classification() {
1474 let rl = ZaiError::RateLimitError {
1479 code: 1302,
1480 message: "slow down".into(),
1481 };
1482 assert_eq!(rl.category(), ErrorCategory::RateLimit);
1483 assert!(rl.is_retryable());
1484 assert!(rl.is_client_error());
1485 assert!(!rl.is_server_error());
1486
1487 let h429 = ZaiError::HttpError {
1489 status: 429,
1490 message: "too many".into(),
1491 };
1492 assert_eq!(h429.category(), ErrorCategory::RateLimit);
1493 assert!(h429.is_retryable());
1494 assert!(h429.is_client_error());
1495
1496 let h500 = ZaiError::HttpError {
1498 status: 500,
1499 message: "boom".into(),
1500 };
1501 assert_eq!(h500.category(), ErrorCategory::Server);
1502 assert!(h500.is_retryable());
1503 assert!(h500.is_server_error());
1504 assert!(!h500.is_client_error());
1505
1506 let h400 = ZaiError::HttpError {
1508 status: 400,
1509 message: "bad".into(),
1510 };
1511 assert_eq!(h400.category(), ErrorCategory::Client);
1512 assert!(!h400.is_retryable());
1513 assert!(h400.is_client_error());
1514
1515 let auth = ZaiError::AuthError {
1517 code: 1001,
1518 message: "bad key".into(),
1519 };
1520 assert_eq!(auth.category(), ErrorCategory::Auth);
1521 assert!(!auth.is_retryable());
1522 assert!(auth.is_client_error());
1523
1524 let unk = ZaiError::Unknown {
1528 code: 503,
1529 message: "?".into(),
1530 };
1531 assert_eq!(unk.category(), ErrorCategory::Server);
1532 assert!(unk.is_server_error());
1533 assert!(!unk.is_retryable());
1534 }
1535
1536 #[test]
1537 fn test_business_code_band_boundaries() {
1538 for code in [1002, 1004, 1100, 1300, 1303, 1304, 1306, 1307, 1312] {
1541 let e = ZaiError::from_api_response(400, code, "gap".to_string());
1542 assert!(
1543 matches!(e, ZaiError::Unknown { .. }),
1544 "code {code} -> Unknown"
1545 );
1546 assert!(!e.is_rate_limit());
1547 }
1548 let e = ZaiError::from_api_response(400, 1499, "file".to_string());
1550 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
1551 let e = ZaiError::from_api_response(400, 1400, "file".to_string());
1553 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
1554 for code in [1113, 1302, 1305, 1308, 1321] {
1556 let e = ZaiError::from_api_response(429, code, "rl".to_string());
1557 assert!(e.is_rate_limit(), "code {code} -> RateLimitError");
1558 }
1559 }
1560
1561 #[test]
1564 fn status_502_503_504_classify_as_server_and_carry_status() {
1565 for status in [502, 503, 504] {
1568 let e = ZaiError::from_api_response(status, 0, String::new());
1569 match &e {
1570 ZaiError::HttpError {
1571 status: s,
1572 message: _,
1573 } => {
1574 assert_eq!(*s, status, "HTTP {status} lost its status code");
1575 assert!(e.is_server_error(), "HTTP {status} not classified Server");
1576 assert!(
1577 e.is_retryable(),
1578 "HTTP {status} should be retryable as a 5xx"
1579 );
1580 },
1581 other => panic!("HTTP {status} classified as {other:?}, expected HttpError"),
1582 }
1583 }
1584 let e = ZaiError::from_api_response(500, 0, String::new());
1586 assert!(matches!(e, ZaiError::HttpError { status: 500, .. }));
1587 assert!(e.is_server_error());
1588 }
1589
1590 #[test]
1591 fn retry_helper_uses_the_transport_status_matrix() {
1592 for status in [408, 425, 429, 500, 502, 503, 504] {
1593 let error = ZaiError::from_api_response(status, 0, String::new());
1594 assert!(error.is_retryable(), "HTTP {status} should be retryable");
1595 }
1596 for status in [400, 501, 505] {
1597 let error = ZaiError::from_api_response(status, 0, String::new());
1598 assert!(!error.is_retryable(), "HTTP {status} must not retry");
1599 }
1600 for status in [501, 505] {
1601 let error = ZaiError::from_api_response(status, 0, String::new());
1602 assert_eq!(error.category(), ErrorCategory::Other);
1603 assert!(!error.is_server_error());
1604 }
1605 }
1606
1607 #[test]
1608 fn status_401_403_classify_as_auth() {
1609 for status in [401, 403] {
1610 let e = ZaiError::from_api_response(status, 0, String::new());
1611 assert!(
1612 e.is_auth_error(),
1613 "HTTP {status} should classify as auth, got {e:?}"
1614 );
1615 assert!(
1616 e.is_client_error(),
1617 "HTTP {status} should be a client error"
1618 );
1619 assert!(
1620 !e.is_retryable(),
1621 "HTTP {status} (auth) should not be retryable"
1622 );
1623 }
1624 }
1625
1626 #[test]
1627 fn status_429_classifies_as_rate_limit() {
1628 let e = ZaiError::from_api_response(429, 0, String::new());
1629 assert!(
1630 e.is_rate_limit(),
1631 "HTTP 429 should classify as rate limit, got {e:?}"
1632 );
1633 assert!(e.is_retryable(), "HTTP 429 should be retryable");
1634 }
1635}