Skip to main content

zai_rs/client/
error.rs

1//! # Error Types
2//!
3//! Defines the unified error type for the ZAI-RS SDK, mapping Zhipu AI API
4//! error codes. See <https://docs.bigmodel.cn/cn/api/api-code> for the full
5//! reference.
6//!
7//! # Error Categories
8//!
9//! | Variant | Code range | Description |
10//! |---------|------------|-------------|
11//! | [`ZaiError::AuthError`] | 1000, 1001, 1003, 1005, 1220 | Authentication / authorization (invalid API key, etc.) |
12//! | [`ZaiError::AccountError`] | 1110–1121 except 1113 | Account/package-related errors |
13//! | [`ZaiError::ApiError`] | 1200–1234, 1261 | Request validation or upstream execution errors |
14//! | [`ZaiError::ContentPolicyError`] | 1301 | API policy / unsafe-content blocks |
15//! | [`ZaiError::RateLimitError`] | 1113, 1302, 1305, 1308–1311, 1313–1321 | Rate-limit, quota, package pressure or fair-use errors |
16//! | [`ZaiError::FileError`] | 1400–1499 | File-processing errors |
17//! | [`ZaiError::Unknown`] | other | Unrecognized business or HTTP errors |
18//! | [`ZaiError::NetworkError`] | — | Network / timeout errors |
19//! | [`ZaiError::JsonError`] | — | JSON serialization / deserialization errors |
20//!
21//! # Sensitive-Data Masking
22//!
23//! [`mask_sensitive_info`] can sanitize text before a caller writes it to a log.
24//! It is an explicit helper, not a logging hook: arbitrary log messages are not
25//! automatically filtered by this module.
26//!
27//! # Example
28//!
29//! ```rust,no_run
30//! use zai_rs::client::error::{ZaiError, ZaiResult};
31//!
32//! async fn call_api() -> ZaiResult<String> {
33//!     Ok("result".to_string())
34//! }
35//!
36//! # async fn example() {
37//! match call_api().await {
38//!     Ok(data) => println!("Success: {}", data),
39//!     Err(ZaiError::AuthError { code, message }) => {
40//!         tracing::error!("Auth failed ({}): {}", code, message);
41//!     },
42//!     Err(ZaiError::RateLimitError { code, message }) => {
43//!         tracing::error!("Rate limited ({}): {}", code, message);
44//!     },
45//!     Err(e) => tracing::error!("Error: {}", e),
46//! }
47//! # }
48//! ```
49
50use std::sync::{Arc, LazyLock};
51
52use regex::Regex;
53use thiserror::Error;
54
55/// Compile a built-in redaction pattern. A bad literal is a programming error:
56/// failing closed is safer than silently disabling credential filtering.
57fn 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            // Redact the entire `Authorization: Bearer <token>`, including the
74            // header name and authentication scheme.
75            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
98/// Mask recognized credentials in `text` before it is written to a log.
99///
100/// Values are replaced with `[FILTERED]`; a complete Authorization header is
101/// replaced with `[AUTH_REDACTED]`.
102///
103/// # Patterns Masked
104///
105/// - API keys (format: `id.secret` where id ≥ 3 chars, secret ≥ 10 chars)
106/// - Password fields
107/// - Token values
108/// - Secret fields
109/// - Bearer tokens
110/// - Authorization headers
111///
112/// # Example
113///
114/// ```
115/// use zai_rs::client::error::mask_sensitive_info;
116///
117/// // API key requires secret >= 10 chars
118/// let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz, password: secret123";
119/// let filtered = mask_sensitive_info(text);
120/// assert!(filtered.contains("[FILTERED]"));
121/// assert!(!filtered.contains("abc123"));
122/// ```
123pub 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
133/// Masks API keys in text
134///
135/// A specialized function that only masks API keys following the ZhipuAI
136/// format.
137pub fn mask_api_key(text: &str) -> String {
138    API_KEY_PATTERN.replace_all(text, "[FILTERED]").into_owned()
139}
140
141/// Return whether `text` matches a recognized credential pattern.
142pub 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
152/// Validate the Zhipu AI API key format.
153///
154/// Zhipu AI API keys follow the format `<id>.<secret>`, where both parts contain
155/// ASCII letters, digits, `_`, or `-`.
156///
157/// # Examples
158///
159/// ```
160/// use zai_rs::client::error::validate_api_key;
161///
162/// // Valid API key (id >= 3 chars, secret >= 10 chars)
163/// assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
164/// assert!(validate_api_key("").is_err());
165/// assert!(validate_api_key("invalid").is_err());
166/// ```
167pub 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
225/// Reserved error-code constants for failures originating inside the SDK
226/// itself (client-side validation, I/O, timeouts, external/toolkit calls).
227///
228/// These never overlap with codes emitted by the Zhipu AI API (documented
229/// range `1000`–`1499`). Every value lives in the reserved `9000`–`9999`
230/// band, so a caller can distinguish "the server rejected this"
231/// (`1000`–`1499`) from "the SDK failed before/after the server replied"
232/// (`9000`–`9999`) via [`ZaiError::code`] / [`ZaiError::is_sdk_error`].
233pub mod codes {
234    /// Generic client-side validation failure (bad argument shape, …).
235    pub const SDK_VALIDATION: u16 = 9001;
236
237    /// Client-side configuration error (bad base URL, missing value, …).
238    pub const SDK_CONFIG: u16 = 9600;
239
240    /// A local file referenced by the request does not exist.
241    pub const SDK_FILE_NOT_FOUND: u16 = 9100;
242
243    /// A local file exceeds the SDK/enforced size limit.
244    pub const SDK_FILE_TOO_LARGE: u16 = 9101;
245
246    /// The file type/extension is not supported by the target tool.
247    pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
248
249    /// Generic local I/O failure (read/write/permission, …).
250    pub const SDK_IO: u16 = 9400;
251
252    /// A client-side timeout (e.g. polling an async task for too long).
253    pub const SDK_TIMEOUT: u16 = 9300;
254
255    /// A failure reported by an external/toolkit source (RMCP, function tool).
256    pub const SDK_EXTERNAL_TOOL: u16 = 9500;
257}
258
259/// Main error type for the ZAI-RS SDK
260#[derive(Error, Debug, Clone)]
261#[non_exhaustive]
262pub enum ZaiError {
263    /// HTTP status errors
264    #[error("HTTP error [{status}]: {message}")]
265    HttpError {
266        /// HTTP status code (e.g. `400`, `404`, `500`).
267        status: u16,
268        /// Human-readable error message returned with the response.
269        message: String,
270    },
271
272    /// Authentication and authorization errors
273    #[error("Authentication error [{code}]: {message}")]
274    AuthError {
275        /// Zhipu AI authentication/authorization business error code.
276        code: u16,
277        /// Human-readable error message.
278        message: String,
279    },
280
281    /// Account-related errors
282    #[error("Account error [{code}]: {message}")]
283    AccountError {
284        /// Zhipu AI account business error code (`1110`–`1121`, excluding
285        /// quota/billing code `1113`).
286        code: u16,
287        /// Human-readable error message.
288        message: String,
289    },
290
291    /// API call errors
292    #[error("API error [{code}]: {message}")]
293    ApiError {
294        /// Zhipu AI business error code (`1200`–`1234` or `1261`) or a
295        /// reserved SDK code from [`codes`] (`9000`–`9999`). Business codes
296        /// `1200`, `1230`, and `1234` are categorized as server failures.
297        code: u16,
298        /// Human-readable error message.
299        message: String,
300    },
301
302    /// Rate limiting and quota errors
303    #[error("Rate limit error [{code}]: {message}")]
304    RateLimitError {
305        /// Zhipu AI rate-limit, quota, or billing business error code.
306        code: u16,
307        /// Human-readable error message.
308        message: String,
309    },
310
311    /// Content policy errors
312    #[error("Content policy error [{code}]: {message}")]
313    ContentPolicyError {
314        /// Zhipu AI business error code `1301` for policy blocks or
315        /// unsafe-content violations.
316        code: u16,
317        /// Human-readable error message.
318        message: String,
319    },
320
321    /// File processing errors
322    #[error("File error [{code}]: {message}")]
323    FileError {
324        /// Zhipu AI business error code (`1400`–`1499`) or a reserved SDK
325        /// file code from [`codes`].
326        code: u16,
327        /// Human-readable error message.
328        message: String,
329    },
330
331    /// Network/IO errors (wrapped in Arc for Clone support). The underlying
332    /// `reqwest::Error` is exposed as the [`Error::source`](std::error::Error::source).
333    #[error("Network error: {0}")]
334    NetworkError(#[source] Arc<reqwest::Error>),
335
336    /// JSON parsing errors (wrapped in Arc for Clone support). The underlying
337    /// `serde_json::Error` is exposed as the [`Error::source`](std::error::Error::source).
338    #[error("JSON error: {0}")]
339    JsonError(#[source] Arc<serde_json::Error>),
340
341    /// Realtime (WebSocket) transport errors — wrapped in `Arc` so the variant
342    /// stays `Clone`-able. See [`RealtimeErrorKind`] for the breakdown. The
343    /// kind is exposed as the [`Error::source`](std::error::Error::source).
344    #[error("Realtime error: {0}")]
345    RealtimeError(#[source] Arc<RealtimeErrorKind>),
346
347    /// Realtime authentication / JWT errors (bad API-key shape, signing
348    /// failure, token rejected during the WebSocket handshake).
349    #[error("Realtime auth error: {0}")]
350    RealtimeAuthError(String),
351
352    /// Other errors
353    #[error("Unknown error [{code}]: {message}")]
354    Unknown {
355        /// Numeric code — either an unmapped business code, an HTTP status,
356        /// or a reserved SDK code from [`codes`].
357        code: u16,
358        /// Human-readable error message.
359        message: String,
360    },
361}
362
363/// Coarse classification of a [`ZaiError`] for retry/recovery decisions.
364///
365/// The single source of truth consulted by [`ZaiError::category`]; the
366/// [`is_client_error`](ZaiError::is_client_error) /
367/// [`is_server_error`](ZaiError::is_server_error) predicates derive from it so
368/// they can never drift apart.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370#[non_exhaustive]
371pub enum ErrorCategory {
372    /// Caller-side (4xx): bad request, bad params, business-rule violation.
373    Client,
374    /// Server-side (5xx): transient backend failure.
375    Server,
376    /// Rate limiting / quota (HTTP 429 and the documented quota business
377    /// codes).
378    RateLimit,
379    /// Network / transport failure (connection, timeout, WebSocket).
380    Network,
381    /// Authentication / authorization — re-auth, do not retry.
382    Auth,
383    /// (De)serialization of a payload — programmer error.
384    Serialization,
385    /// Anything not covered above.
386    Other,
387}
388
389/// Map a raw HTTP/business status code to an [`ErrorCategory`].
390fn 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/// Concrete error categories for the realtime (WebSocket) transport.
400///
401/// Kept separate from [`ZaiError`] so callers can introspect the failure mode
402/// without matching on the full enum, and so the realtime module can construct
403/// rich errors without touching HTTP-specific machinery.
404#[derive(Debug, thiserror::Error)]
405#[non_exhaustive]
406pub enum RealtimeErrorKind {
407    /// Low-level WebSocket error (connect/handshake/read/write). The original
408    /// `tungstenite` error is kept as the `#[source]` so the full chain
409    /// survives propagation. Only available with the `realtime` feature.
410    #[cfg(feature = "realtime")]
411    #[error("websocket: {source}")]
412    WebSocket {
413        /// The underlying tungstenite error.
414        #[source]
415        source: tokio_tungstenite::tungstenite::Error,
416    },
417
418    /// Protocol violation — unexpected or malformed server event.
419    #[error("protocol: {0}")]
420    Protocol(String),
421
422    /// A realtime connect, write, or close operation exceeded its deadline.
423    #[error("{operation} timed out")]
424    Timeout {
425        /// Operation whose deadline elapsed.
426        operation: &'static str,
427    },
428
429    /// The WebSocket session has been closed.
430    #[error("session closed")]
431    Closed,
432}
433
434impl ZaiError {
435    /// Convert an HTTP status code and API error response to a [`ZaiError`].
436    pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
437        // Provider and proxy error bodies are untrusted and occasionally echo
438        // request metadata. Remove recognizable credentials before the text
439        // enters a public, loggable error value.
440        let api_message = mask_sensitive_info(&api_message);
441        if api_code != 0 {
442            return match api_code {
443                // Authentication errors
444                1000 | 1001 | 1003 | 1005 | 1220 => ZaiError::AuthError {
445                    code: api_code,
446                    message: api_message,
447                },
448                // Billing exhaustion is surfaced with the other quota errors,
449                // not as a generic account-state failure.
450                1113 => ZaiError::RateLimitError {
451                    code: api_code,
452                    message: api_message,
453                },
454                // Account/package/balance errors
455                1110..=1121 => ZaiError::AccountError {
456                    code: api_code,
457                    message: api_message,
458                },
459                // API call and validation errors. Code 1261 is the documented
460                // context-window validation failure outside the main 12xx
461                // range used by the other request errors.
462                1200..=1234 | 1261 => ZaiError::ApiError {
463                    code: api_code,
464                    message: api_message,
465                },
466                // API policy and unsafe-content blocks are not transient.
467                1301 => ZaiError::ContentPolicyError {
468                    code: api_code,
469                    message: api_message,
470                },
471                // Rate limiting, quota, package access pressure/fair-use errors.
472                1302 | 1305 | 1308..=1311 | 1313..=1321 => ZaiError::RateLimitError {
473                    code: api_code,
474                    message: api_message,
475                },
476                // File processing errors
477                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        // Fall back to HTTP status when no business code is present. Every 5xx
489        // remains an HttpError carrying the real status; 401/403 are classified
490        // as authentication errors and 429 as rate limiting.
491        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            // All 5xx keep the status so classification can use it directly.
517            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    /// Check if the error is a rate limit error
537    pub fn is_rate_limit(&self) -> bool {
538        self.category() == ErrorCategory::RateLimit
539    }
540
541    /// Check if the error is an authentication error
542    pub fn is_auth_error(&self) -> bool {
543        self.category() == ErrorCategory::Auth
544    }
545
546    /// Classify this error into a single canonical [`ErrorCategory`].
547    ///
548    /// This is the one place the SDK decides whether an error is client-side,
549    /// server-side, a rate limit, a network blip, etc. The convenience
550    /// predicates ([`is_client_error`](Self::is_client_error),
551    /// [`is_server_error`](Self::is_server_error)) derive from it, so they can
552    /// never disagree.
553    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                // Protocol failures are client-caused; transport timeouts and
571                // WebSocket failures are network-level; closure is neither.
572                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            // `Unknown` mirrors an unmapped HTTP/business code: 5xx is a server
580            // error, everything else is uncategorized. It is intentionally *not*
581            // classified as client-side or rate-limited even at 4xx/429, matching
582            // the legacy predicate behavior (and it is not retried — its
583            // transience is uncertain).
584            ZaiError::Unknown { code, .. } => {
585                if (500..600).contains(code) {
586                    ErrorCategory::Server
587                } else {
588                    ErrorCategory::Other
589                }
590            },
591        }
592    }
593
594    /// Check if the error is a client error (4xx), including auth and rate
595    /// limiting (which arrive as 4xx responses).
596    pub fn is_client_error(&self) -> bool {
597        matches!(
598            self.category(),
599            ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
600        )
601    }
602
603    /// Check if the error is a server error (5xx).
604    pub fn is_server_error(&self) -> bool {
605        matches!(self.category(), ErrorCategory::Server)
606    }
607
608    /// Whether retrying the request that produced this error could succeed.
609    ///
610    /// This caller-facing helper marks transient rate limits (`429`, `1302`,
611    /// `1305`), documented upstream execution failures, network failures, and
612    /// HTTP 5xx errors as potentially retryable. Quota/billing exhaustion is
613    /// deliberately not retryable. This method does not account for request
614    /// idempotency or attempt limits; the internal HTTP transport applies those
615    /// additional constraints.
616    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    /// Whether this error originates from the SDK itself rather than the API.
641    ///
642    /// True iff [`code`](Self::code) is in the reserved `9000`–`9999` band
643    /// (see [`codes`]). Variants without a numeric code
644    /// ([`NetworkError`](Self::NetworkError), [`JsonError`](Self::JsonError),
645    /// [`RealtimeError`](Self::RealtimeError)) return `false`.
646    pub fn is_sdk_error(&self) -> bool {
647        self.code().is_some_and(|c| (9000..=9999).contains(&c))
648    }
649
650    /// Get a compact representation of error suitable for logging
651    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    /// Get error code if available
693    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    /// Get error message
710    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    /// Attach an operational context to this error without losing its code or
728    /// category.
729    ///
730    /// Prepends `"{context}: "` to the human-readable message of every variant
731    /// that carries one. Variants whose payload is a wrapped source error with
732    /// no message slot ([`NetworkError`](Self::NetworkError),
733    /// [`JsonError`](Self::JsonError), [`RealtimeError`](Self::RealtimeError))
734    /// are returned unchanged — record their context in a `tracing` span
735    /// instead.
736    ///
737    /// # Example
738    ///
739    /// ```
740    /// use zai_rs::client::error::ZaiError;
741    ///
742    /// let err = ZaiError::ApiError {
743    ///     code: 1200,
744    ///     message: "bad model".to_string(),
745    /// };
746    /// let ctx = err.context("file parser create");
747    /// assert_eq!(ctx.code(), Some(1200));
748    /// assert_eq!(ctx.message(), "file parser create: bad model");
749    /// ```
750    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            // No message slot: keep the wrapped source as-is (context belongs
782            // in a tracing span, not by flattening the source to a string).
783            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
795/// Business errors documented by the upstream API as server-side execution
796/// failures even though they share the broad `12xx` API-error namespace.
797const 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
809/// Type alias for Result with ZaiError
810pub type ZaiResult<T> = Result<T, ZaiError>;
811
812/// Convert from reqwest::Error to ZaiError
813impl From<reqwest::Error> for ZaiError {
814    fn from(err: reqwest::Error) -> Self {
815        // Reqwest attaches the request URL to many errors. Strip it before the
816        // error enters the public type so paths and query values cannot leak
817        // through Display, Debug, `compact()`, or tracing.
818        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
827/// Convert from serde_json::Error to ZaiError
828impl From<serde_json::Error> for ZaiError {
829    fn from(err: serde_json::Error) -> Self {
830        ZaiError::JsonError(Arc::new(err))
831    }
832}
833
834/// Convert from validator::ValidationErrors to ZaiError
835impl 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
895/// Convert from std::io::Error to ZaiError.
896///
897/// Maps by [`std::io::ErrorKind`] so the category (file vs. timeout vs.
898/// generic I/O) survives propagation instead of collapsing to a single
899/// opaque `Unknown{0}`. A `NetworkError` cannot be built from an
900/// `io::Error` (it wraps `reqwest::Error`), so `TimedOut` is reported as an
901/// [`ApiError`](Self::ApiError) carrying [`codes::SDK_TIMEOUT`].
902impl 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
926/// Convert from a realtime transport error kind into a [`ZaiError`].
927impl From<RealtimeErrorKind> for ZaiError {
928    fn from(kind: RealtimeErrorKind) -> Self {
929        ZaiError::RealtimeError(Arc::new(kind))
930    }
931}
932
933/// Convert from a low-level WebSocket (`tungstenite`) error into a
934/// [`ZaiError`]. The original error is preserved as the `#[source]` of
935/// [`RealtimeErrorKind::WebSocket`]. Only available with the `realtime`
936/// feature.
937#[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    // `super::*` would otherwise pull in the `thiserror::Error` derive macro as
948    // `Error`; bring `std::io`'s `Error`/`ErrorKind` into scope for the io-Error
949    // conversion tests below.
950    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        // Business code takes precedence over HTTP status.
1005        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        // API code 1302 returns RateLimitError even with a non-error HTTP
1011        // status.
1012        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()); // Unknown code doesn't mean client error
1101        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        // Using From trait implementation for io::Error: ErrorKind::ConnectionRefused
1122        // is not NotFound/PermissionDenied/TimedOut, so it falls through to
1123        // Unknown carrying the SDK I/O code.
1124        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        // JsonError has no code
1129        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        // HttpError has status as code
1136        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        // SDK codes → true.
1184        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        // API / HTTP codes → false.
1200        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        // Code-less variants → false.
1223        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        // Unmapped kind → Unknown with SDK_IO code (no longer code 0).
1249        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        // Regression guard: a client-side polling timeout must NOT masquerade
1281        // as a rate-limit error (the previous implementation returned
1282        // RateLimitError{code:0}).
1283        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        // The whole Authorization header is replaced, including the header name
1401        // and Bearer scheme.
1402        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        // Single source of truth: `category()` drives is_client_error /
1475        // is_server_error / is_retryable. Spot-check the classification table.
1476
1477        // Rate-limit business error: client-side AND retryable.
1478        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        // HTTP 429 -> rate limit, retryable, client-side.
1488        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        // HTTP 500 -> server, retryable, not client.
1497        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        // HTTP 400 -> client, NOT retryable.
1507        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        // Auth -> client-side, not retryable.
1516        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        // Unknown 5xx is reported as a server error but, by design, is NOT
1525        // retried (its transience is uncertain) — this is the one intentional
1526        // divergence between `is_server_error` and `is_retryable`.
1527        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        // Undocumented gaps remain Unknown instead of being absorbed by broad
1539        // numeric ranges.
1540        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        // 1499 is the inclusive top of the FileError band (1400-1499).
1549        let e = ZaiError::from_api_response(400, 1499, "file".to_string());
1550        assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
1551        // 1400 is the bottom of the FileError band.
1552        let e = ZaiError::from_api_response(400, 1400, "file".to_string());
1553        assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
1554        // Documented rate-limit band edges and billing code are rate-limit errors.
1555        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    // ----- HTTP status classification for status-only responses -----
1562
1563    #[test]
1564    fn status_502_503_504_classify_as_server_and_carry_status() {
1565        // Gateway/server failures retain their real status and classify as
1566        // server-side and retryable.
1567        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        // 500 stays Server too.
1585        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}