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–1004, 1100 | Authentication / authorization (invalid API key, etc.) |
12//! | [`ZaiError::AccountError`] | 1110–1121 | Account/package-related errors |
13//! | [`ZaiError::ApiError`] | 1200–1234 | Request validation / API call errors |
14//! | [`ZaiError::ContentPolicyError`] | 1300–1301 | API policy / unsafe-content blocks |
15//! | [`ZaiError::RateLimitError`] | 1302–1305, 1308–1313 | 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//! ```text
30//! use zai_rs::client::error::{ZaiError, ZaiResult};
31//!
32//! async fn call_api() -> ZaiResult<String> {
33//!     // ... API call ...
34//!     Ok("result".to_string())
35//! }
36//!
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
49use std::sync::{Arc, LazyLock};
50
51use regex::Regex;
52use thiserror::Error;
53
54/// Pre-compiled regex patterns for sensitive data masking (avoids recompilation
55/// on every call). Every pattern is a static literal, so each `Regex::new` here
56/// always succeeds — but the plumbing stores `Option`/filtered vecs and resolves
57/// via `.ok()` rather than `.expect()`, keeping the crate's no-`unwrap`/`expect`
58/// policy honest (a malformed literal would be skipped, not panic at first use).
59static API_KEY_PATTERN: LazyLock<Option<Regex>> =
60    LazyLock::new(|| Regex::new(r"\b[a-zA-Z0-9_-]{3,}\.[a-zA-Z0-9_-]{10,}\b").ok());
61
62static SENSITIVE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
63    [
64        (r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
65        (r"(?i)(password\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
66        (r"(?i)(token\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
67        (r"(?i)(secret\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
68        (
69            r"(?i)(bearer\s+)[a-zA-Z0-9_-]+\.([a-zA-Z0-9_-]{10,})",
70            "$1[FILTERED]",
71        ),
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    .filter_map(|(pat, repl)| Regex::new(pat).ok().map(|re| (re, repl)))
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)authorization\s*:\s*Bearer",
91    ]
92    .into_iter()
93    .filter_map(|pat| Regex::new(pat).ok())
94    .collect()
95});
96
97/// Masks sensitive information in text for secure logging
98///
99/// This function filters out potentially sensitive data such as API keys,
100/// passwords, and tokens from log messages.
101///
102/// # Arguments
103///
104/// * `text` - The text to filter
105///
106/// # Returns
107///
108/// Text with sensitive information masked as `[FILTERED]`, or
109/// `[AUTH_REDACTED]` for a complete Authorization header
110///
111/// # Patterns Masked
112///
113/// - API keys (format: `id.secret` where id ≥ 3 chars, secret ≥ 10 chars)
114/// - Password fields
115/// - Token values
116/// - Secret fields
117/// - Bearer tokens
118/// - Authorization headers
119///
120/// # Example
121///
122/// ```
123/// use zai_rs::client::error::mask_sensitive_info;
124///
125/// // API key requires secret >= 10 chars
126/// let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz, password: secret123";
127/// let filtered = mask_sensitive_info(text);
128/// assert!(filtered.contains("[FILTERED]"));
129/// assert!(!filtered.contains("abc123"));
130/// ```
131pub fn mask_sensitive_info(text: &str) -> String {
132    let mut result = match API_KEY_PATTERN.as_ref() {
133        Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
134        None => text.to_string(),
135    };
136
137    for (re, replacement) in SENSITIVE_PATTERNS.iter() {
138        result = re.replace_all(&result, *replacement).into_owned();
139    }
140
141    result
142}
143
144/// Masks API keys in text
145///
146/// A specialized function that only masks API keys following the ZhipuAI
147/// format.
148pub fn mask_api_key(text: &str) -> String {
149    match API_KEY_PATTERN.as_ref() {
150        Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
151        None => text.to_string(),
152    }
153}
154
155/// Checks if text contains sensitive information patterns
156pub fn contains_sensitive_info(text: &str) -> bool {
157    if API_KEY_PATTERN.as_ref().is_some_and(|re| re.is_match(text)) {
158        return true;
159    }
160
161    CONTAINS_SENSITIVE_PATTERNS
162        .iter()
163        .any(|re| re.is_match(text))
164}
165
166/// Validates Zhipu AI API key format
167///
168/// Zhipu AI API keys follow the format `<id>.<secret>`, where both parts contain
169/// letters, digits, `_`, or `-`.
170///
171/// # Arguments
172///
173/// * `api_key` - The API key to validate
174///
175/// # Returns
176///
177/// * `Ok(())` if API key is valid
178/// * `Err(ZaiError)` if API key is invalid
179///
180/// # Example
181///
182/// ```
183/// use zai_rs::client::error::validate_api_key;
184///
185/// // Valid API key (id >= 3 chars, secret >= 10 chars)
186/// assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
187/// assert!(validate_api_key("").is_err());
188/// assert!(validate_api_key("invalid").is_err());
189/// ```
190pub fn validate_api_key(api_key: &str) -> ZaiResult<()> {
191    if api_key.is_empty() {
192        return Err(ZaiError::ApiError {
193            code: codes::SDK_VALIDATION,
194            message: "API key cannot be empty".to_string(),
195        });
196    }
197
198    let parts: Vec<&str> = api_key.split('.').collect();
199    if parts.len() != 2 {
200        return Err(ZaiError::ApiError {
201            code: codes::SDK_VALIDATION,
202            message: "API key must be in format '<id>.<secret>'".to_string(),
203        });
204    }
205
206    let (id, secret) = (parts[0], parts[1]);
207
208    if id.is_empty() || secret.is_empty() {
209        return Err(ZaiError::ApiError {
210            code: codes::SDK_VALIDATION,
211            message: "API key id and secret must not be empty".to_string(),
212        });
213    }
214
215    // Check if parts contain only valid characters (alphanumeric and some special
216    // chars)
217    let valid_chars = |s: &str| -> bool {
218        s.chars()
219            .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
220    };
221
222    if !valid_chars(id) || !valid_chars(secret) {
223        return Err(ZaiError::ApiError {
224            code: codes::SDK_VALIDATION,
225            message: "API key contains invalid characters".to_string(),
226        });
227    }
228
229    // Check reasonable length (id should be at least 3 chars, secret at least 10
230    // chars)
231    if id.len() < 3 {
232        return Err(ZaiError::ApiError {
233            code: codes::SDK_VALIDATION,
234            message: "API key id is too short".to_string(),
235        });
236    }
237
238    if secret.len() < 10 {
239        return Err(ZaiError::ApiError {
240            code: codes::SDK_VALIDATION,
241            message: "API key secret is too short".to_string(),
242        });
243    }
244
245    Ok(())
246}
247
248/// Reserved error-code constants for failures originating inside the SDK
249/// itself (client-side validation, I/O, timeouts, external/toolkit calls).
250///
251/// These never overlap with codes emitted by the Zhipu AI API (documented
252/// range `1000`–`1499`). Every value lives in the reserved `9000`–`9999`
253/// band, so a caller can distinguish "the server rejected this"
254/// (`1000`–`1499`) from "the SDK failed before/after the server replied"
255/// (`9000`–`9999`) via [`ZaiError::code`] / [`ZaiError::is_sdk_error`].
256pub mod codes {
257    /// Generic client-side validation failure (bad argument shape, …).
258    pub const SDK_VALIDATION: u16 = 9001;
259
260    /// Client-side configuration error (bad base URL, missing value, …).
261    pub const SDK_CONFIG: u16 = 9600;
262
263    /// A local file referenced by the request does not exist.
264    pub const SDK_FILE_NOT_FOUND: u16 = 9100;
265
266    /// A local file exceeds the SDK/enforced size limit.
267    pub const SDK_FILE_TOO_LARGE: u16 = 9101;
268
269    /// The file type/extension is not supported by the target tool.
270    pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
271
272    /// Generic local I/O failure (read/write/permission, …).
273    pub const SDK_IO: u16 = 9400;
274
275    /// A client-side timeout (e.g. polling an async task for too long).
276    pub const SDK_TIMEOUT: u16 = 9300;
277
278    /// A failure reported by an external/toolkit source (RMCP, function tool).
279    pub const SDK_EXTERNAL_TOOL: u16 = 9500;
280}
281
282/// Main error type for the ZAI-RS SDK
283#[derive(Error, Debug, Clone)]
284#[non_exhaustive]
285pub enum ZaiError {
286    /// HTTP status errors
287    #[error("HTTP error [{status}]: {message}")]
288    HttpError {
289        /// HTTP status code (e.g. `400`, `404`, `500`).
290        status: u16,
291        /// Human-readable error message returned with the response.
292        message: String,
293    },
294
295    /// Authentication and authorization errors
296    #[error("Authentication error [{code}]: {message}")]
297    AuthError {
298        /// Zhipu AI business error code (`1000`–`1004`, `1100`).
299        code: u16,
300        /// Human-readable error message.
301        message: String,
302    },
303
304    /// Account-related errors
305    #[error("Account error [{code}]: {message}")]
306    AccountError {
307        /// Zhipu AI business error code (`1110`–`1121`).
308        code: u16,
309        /// Human-readable error message.
310        message: String,
311    },
312
313    /// API call errors
314    #[error("API error [{code}]: {message}")]
315    ApiError {
316        /// Zhipu AI business error code (`1200`–`1234`) or a reserved SDK
317        /// code from [`codes`] (`9000`–`9999`).
318        code: u16,
319        /// Human-readable error message.
320        message: String,
321    },
322
323    /// Rate limiting and quota errors
324    #[error("Rate limit error [{code}]: {message}")]
325    RateLimitError {
326        /// Zhipu AI business error code (`1302`–`1305`, `1308`–`1313`).
327        code: u16,
328        /// Human-readable error message.
329        message: String,
330    },
331
332    /// Content policy errors
333    #[error("Content policy error [{code}]: {message}")]
334    ContentPolicyError {
335        /// Zhipu AI business error code (`1300`–`1301`) for policy blocks or
336        /// unsafe-content violations.
337        code: u16,
338        /// Human-readable error message.
339        message: String,
340    },
341
342    /// File processing errors
343    #[error("File error [{code}]: {message}")]
344    FileError {
345        /// Zhipu AI business error code (`1400`–`1499`) or a reserved SDK
346        /// file code from [`codes`].
347        code: u16,
348        /// Human-readable error message.
349        message: String,
350    },
351
352    /// Network/IO errors (wrapped in Arc for Clone support). The underlying
353    /// `reqwest::Error` is exposed as the [`Error::source`](std::error::Error::source).
354    #[error("Network error: {0}")]
355    NetworkError(#[source] Arc<reqwest::Error>),
356
357    /// JSON parsing errors (wrapped in Arc for Clone support). The underlying
358    /// `serde_json::Error` is exposed as the [`Error::source`](std::error::Error::source).
359    #[error("JSON error: {0}")]
360    JsonError(#[source] Arc<serde_json::Error>),
361
362    /// Realtime (WebSocket) transport errors — wrapped in `Arc` so the variant
363    /// stays `Clone`-able. See [`RealtimeErrorKind`] for the breakdown. The
364    /// kind is exposed as the [`Error::source`](std::error::Error::source).
365    #[error("Realtime error: {0}")]
366    RealtimeError(#[source] Arc<RealtimeErrorKind>),
367
368    /// Realtime authentication / JWT errors (bad API-key shape, signing
369    /// failure, token rejected during the WebSocket handshake).
370    #[error("Realtime auth error: {0}")]
371    RealtimeAuthError(String),
372
373    /// Other errors
374    #[error("Unknown error [{code}]: {message}")]
375    Unknown {
376        /// Numeric code — either an unmapped business code, an HTTP status,
377        /// or a reserved SDK code from [`codes`].
378        code: u16,
379        /// Human-readable error message.
380        message: String,
381    },
382}
383
384/// Coarse classification of a [`ZaiError`] for retry/recovery decisions.
385///
386/// The single source of truth consulted by [`ZaiError::category`]; the
387/// [`is_client_error`](ZaiError::is_client_error) /
388/// [`is_server_error`](ZaiError::is_server_error) predicates derive from it so
389/// they can never drift apart.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391#[non_exhaustive]
392pub enum ErrorCategory {
393    /// Caller-side (4xx): bad request, bad params, business-rule violation.
394    Client,
395    /// Server-side (5xx): transient backend failure.
396    Server,
397    /// Rate limiting / quota (HTTP 429, business `1302`–`1313`).
398    RateLimit,
399    /// Network / transport failure (connection, timeout, WebSocket).
400    Network,
401    /// Authentication / authorization — re-auth, do not retry.
402    Auth,
403    /// (De)serialization of a payload — programmer error.
404    Serialization,
405    /// Anything not covered above.
406    Other,
407}
408
409/// Map a raw HTTP/business status code to an [`ErrorCategory`].
410fn classify_status(status: u16) -> ErrorCategory {
411    match status {
412        429 => ErrorCategory::RateLimit,
413        s if (400..500).contains(&s) => ErrorCategory::Client,
414        s if (500..600).contains(&s) => ErrorCategory::Server,
415        _ => ErrorCategory::Other,
416    }
417}
418
419/// Concrete error categories for the realtime (WebSocket) transport.
420///
421/// Kept separate from [`ZaiError`] so callers can introspect the failure mode
422/// without matching on the full enum, and so the realtime module can construct
423/// rich errors without touching HTTP-specific machinery.
424#[derive(Debug, thiserror::Error)]
425#[non_exhaustive]
426pub enum RealtimeErrorKind {
427    /// Low-level WebSocket error (connect/handshake/read/write). The original
428    /// `tungstenite` error is kept as the `#[source]` so the full chain
429    /// survives propagation. Only available with the `realtime` feature.
430    #[cfg(feature = "realtime")]
431    #[error("websocket: {source}")]
432    WebSocket {
433        /// The underlying tungstenite error.
434        #[source]
435        source: tokio_tungstenite::tungstenite::Error,
436    },
437
438    /// (De)serialization of a realtime event failed.
439    #[error("serialize: {source}")]
440    Serialize {
441        /// The underlying serde_json error.
442        #[source]
443        source: serde_json::Error,
444    },
445
446    /// Protocol violation — unexpected or malformed server event.
447    #[error("protocol: {0}")]
448    Protocol(String),
449
450    /// The server emitted an `error` event.
451    #[error("server error event [code={code:?}]: {message}")]
452    ServerEvent {
453        /// Machine-readable error code (may be numeric or textual).
454        code: String,
455        /// Human-readable error message.
456        message: String,
457    },
458
459    /// The WebSocket session has been closed.
460    #[error("session closed")]
461    Closed,
462}
463
464impl ZaiError {
465    /// Convert an HTTP status code and API error response to a ZaiError
466    pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
467        if api_code != 0 {
468            return match api_code {
469                // Authentication errors
470                1000..=1004 | 1100 => ZaiError::AuthError {
471                    code: api_code,
472                    message: api_message,
473                },
474                // Account/package/balance errors
475                1110..=1121 => ZaiError::AccountError {
476                    code: api_code,
477                    message: api_message,
478                },
479                // API call/validation errors
480                1200..=1234 => ZaiError::ApiError {
481                    code: api_code,
482                    message: api_message,
483                },
484                // API policy and unsafe-content blocks are not transient.
485                1300..=1301 => ZaiError::ContentPolicyError {
486                    code: api_code,
487                    message: api_message,
488                },
489                // Rate limiting, quota, package access pressure/fair-use errors.
490                1302..=1305 | 1308..=1313 => ZaiError::RateLimitError {
491                    code: api_code,
492                    message: api_message,
493                },
494                // File processing errors
495                1400..=1499 => ZaiError::FileError {
496                    code: api_code,
497                    message: api_message,
498                },
499                _ => ZaiError::Unknown {
500                    code: api_code,
501                    message: if api_message.is_empty() {
502                        "Unknown error".to_string()
503                    } else {
504                        api_message
505                    },
506                },
507            };
508        }
509
510        // Fall back to HTTP status when no business code is present. Every 5xx
511        // remains an HttpError carrying the real status; 401/403 are classified
512        // as authentication errors and 429 as rate limiting.
513        match status {
514            400 => ZaiError::HttpError {
515                status,
516                message: if api_message.is_empty() {
517                    "Bad request - check your parameters".to_string()
518                } else {
519                    api_message
520                },
521            },
522            401 | 403 => ZaiError::AuthError {
523                code: status,
524                message: if api_message.is_empty() {
525                    "Unauthorized - check your API key".to_string()
526                } else {
527                    api_message
528                },
529            },
530            404 => ZaiError::HttpError {
531                status,
532                message: "Not found - requested resource doesn't exist".to_string(),
533            },
534            429 => ZaiError::RateLimitError {
535                code: status,
536                message: if api_message.is_empty() {
537                    "Too many requests - rate limit exceeded".to_string()
538                } else {
539                    api_message
540                },
541            },
542            434 => ZaiError::HttpError {
543                status,
544                message: "No API permission - feature not available".to_string(),
545            },
546            435 => ZaiError::HttpError {
547                status,
548                message: "File size exceeds 100MB limit".to_string(),
549            },
550            // All 5xx keep the status so classification can use it directly.
551            s if (500..600).contains(&s) => ZaiError::HttpError {
552                status,
553                message: if api_message.is_empty() {
554                    format!("Server error (HTTP {status}) - try again later")
555                } else {
556                    api_message
557                },
558            },
559            _ => ZaiError::Unknown {
560                code: status,
561                message: if api_message.is_empty() {
562                    "Unknown error".to_string()
563                } else {
564                    api_message
565                },
566            },
567        }
568    }
569
570    /// Check if the error is a rate limit error
571    pub fn is_rate_limit(&self) -> bool {
572        matches!(self, ZaiError::RateLimitError { .. })
573    }
574
575    /// Check if the error is an authentication error
576    pub fn is_auth_error(&self) -> bool {
577        matches!(self, ZaiError::AuthError { .. })
578    }
579
580    /// Classify this error into a single canonical [`ErrorCategory`].
581    ///
582    /// This is the one place the SDK decides whether an error is client-side,
583    /// server-side, a rate limit, a network blip, etc. The convenience
584    /// predicates ([`is_client_error`](Self::is_client_error),
585    /// [`is_server_error`](Self::is_server_error)) derive from it, so they can
586    /// never disagree.
587    pub fn category(&self) -> ErrorCategory {
588        match self {
589            ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
590            ZaiError::NetworkError(_) => ErrorCategory::Network,
591            ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
592            ZaiError::AccountError { .. }
593            | ZaiError::ApiError { .. }
594            | ZaiError::ContentPolicyError { .. }
595            | ZaiError::FileError { .. } => ErrorCategory::Client,
596            ZaiError::JsonError(_) => ErrorCategory::Serialization,
597            ZaiError::RealtimeError(kind) => match kind.as_ref() {
598                // Protocol/serialize/server-event failures are client-caused;
599                // transport failures are network-level; closure is neither.
600                RealtimeErrorKind::Protocol(_)
601                | RealtimeErrorKind::Serialize { .. }
602                | RealtimeErrorKind::ServerEvent { .. } => ErrorCategory::Client,
603                #[cfg(feature = "realtime")]
604                RealtimeErrorKind::WebSocket { .. } => ErrorCategory::Network,
605                RealtimeErrorKind::Closed => ErrorCategory::Other,
606            },
607            ZaiError::HttpError { status, .. } => classify_status(*status),
608            // `Unknown` mirrors an unmapped HTTP/business code: 5xx is a server
609            // error, everything else is uncategorized. It is intentionally *not*
610            // classified as client-side or rate-limited even at 4xx/429, matching
611            // the legacy predicate behavior (and it is not retried — its
612            // transience is uncertain).
613            ZaiError::Unknown { code, .. } => {
614                if (500..600).contains(code) {
615                    ErrorCategory::Server
616                } else {
617                    ErrorCategory::Other
618                }
619            },
620        }
621    }
622
623    /// Check if the error is a client error (4xx), including auth and rate
624    /// limiting (which arrive as 4xx responses).
625    pub fn is_client_error(&self) -> bool {
626        matches!(
627            self.category(),
628            ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
629        )
630    }
631
632    /// Check if the error is a server error (5xx).
633    pub fn is_server_error(&self) -> bool {
634        matches!(self.category(), ErrorCategory::Server)
635    }
636
637    /// Whether retrying the request that produced this error could succeed.
638    ///
639    /// This caller-facing helper marks rate-limit, network, HTTP 429, and HTTP
640    /// 5xx errors as potentially retryable. It does not account for request
641    /// idempotency or attempt limits. The internal HTTP transport uses its own
642    /// status-and-business-code matrix before constructing a final `ZaiError`.
643    pub fn is_retryable(&self) -> bool {
644        match self {
645            ZaiError::HttpError { status, .. } => *status == 429 || (500..600).contains(status),
646            ZaiError::RateLimitError { .. } => true,
647            ZaiError::NetworkError(_) => true,
648            _ => false,
649        }
650    }
651
652    /// Whether this error originates from the SDK itself rather than the API.
653    ///
654    /// True iff [`code`](Self::code) is in the reserved `9000`–`9999` band
655    /// (see [`codes`]). Variants without a numeric code
656    /// ([`NetworkError`](Self::NetworkError), [`JsonError`](Self::JsonError),
657    /// [`RealtimeError`](Self::RealtimeError)) return `false`.
658    pub fn is_sdk_error(&self) -> bool {
659        self.code().is_some_and(|c| (9000..=9999).contains(&c))
660    }
661
662    /// Get a compact representation of error suitable for logging
663    pub fn compact(&self) -> String {
664        match self {
665            ZaiError::HttpError { status, message } => {
666                format!("HTTP[{status}]: {message}")
667            },
668            ZaiError::AuthError { code, message } => {
669                format!("AUTH[{code}]: {message}")
670            },
671            ZaiError::AccountError { code, message } => {
672                format!("ACCOUNT[{code}]: {message}")
673            },
674            ZaiError::ApiError { code, message } => {
675                format!("API[{code}]: {message}")
676            },
677            ZaiError::RateLimitError { code, message } => {
678                format!("RATE_LIMIT[{code}]: {message}")
679            },
680            ZaiError::ContentPolicyError { code, message } => {
681                format!("POLICY[{code}]: {message}")
682            },
683            ZaiError::FileError { code, message } => {
684                format!("FILE[{code}]: {message}")
685            },
686            ZaiError::NetworkError(err) => {
687                format!("NETWORK: {err}")
688            },
689            ZaiError::JsonError(err) => {
690                format!("JSON: {err}")
691            },
692            ZaiError::RealtimeError(kind) => {
693                format!("REALTIME: {kind}")
694            },
695            ZaiError::RealtimeAuthError(msg) => {
696                format!("REALTIME_AUTH: {msg}")
697            },
698            ZaiError::Unknown { code, message } => {
699                format!("UNKNOWN[{code}]: {message}")
700            },
701        }
702    }
703
704    /// Get error code if available
705    pub fn code(&self) -> Option<u16> {
706        match self {
707            ZaiError::HttpError { status, .. } => Some(*status),
708            ZaiError::AuthError { code, .. } => Some(*code),
709            ZaiError::AccountError { code, .. } => Some(*code),
710            ZaiError::ApiError { code, .. } => Some(*code),
711            ZaiError::RateLimitError { code, .. } => Some(*code),
712            ZaiError::ContentPolicyError { code, .. } => Some(*code),
713            ZaiError::FileError { code, .. } => Some(*code),
714            ZaiError::NetworkError(_) => None,
715            ZaiError::JsonError(_) => None,
716            ZaiError::RealtimeError(_) | ZaiError::RealtimeAuthError(_) => None,
717            ZaiError::Unknown { code, .. } => Some(*code),
718        }
719    }
720
721    /// Get error message
722    pub fn message(&self) -> String {
723        match self {
724            ZaiError::HttpError { message, .. } => message.clone(),
725            ZaiError::AuthError { message, .. } => message.clone(),
726            ZaiError::AccountError { message, .. } => message.clone(),
727            ZaiError::ApiError { message, .. } => message.clone(),
728            ZaiError::RateLimitError { message, .. } => message.clone(),
729            ZaiError::ContentPolicyError { message, .. } => message.clone(),
730            ZaiError::FileError { message, .. } => message.clone(),
731            ZaiError::NetworkError(err) => err.to_string(),
732            ZaiError::JsonError(err) => err.to_string(),
733            ZaiError::RealtimeError(kind) => kind.to_string(),
734            ZaiError::RealtimeAuthError(msg) => msg.clone(),
735            ZaiError::Unknown { message, .. } => message.clone(),
736        }
737    }
738
739    /// Attach an operational context to this error without losing its code or
740    /// category.
741    ///
742    /// Prepends `"{context}: "` to the human-readable message of every variant
743    /// that carries one. Variants whose payload is a wrapped source error with
744    /// no message slot ([`NetworkError`](Self::NetworkError),
745    /// [`JsonError`](Self::JsonError), [`RealtimeError`](Self::RealtimeError))
746    /// are returned unchanged — record their context in a `tracing` span
747    /// instead.
748    ///
749    /// # Example
750    ///
751    /// ```
752    /// use zai_rs::client::error::ZaiError;
753    ///
754    /// let err = ZaiError::ApiError {
755    ///     code: 1200,
756    ///     message: "bad model".to_string(),
757    /// };
758    /// let ctx = err.context("file parser create");
759    /// assert_eq!(ctx.code(), Some(1200));
760    /// assert_eq!(ctx.message(), "file parser create: bad model");
761    /// ```
762    pub fn context(self, context: &str) -> Self {
763        let with_context = |message: String| format!("{context}: {message}");
764        match self {
765            Self::HttpError { status, message } => Self::HttpError {
766                status,
767                message: with_context(message),
768            },
769            Self::AuthError { code, message } => Self::AuthError {
770                code,
771                message: with_context(message),
772            },
773            Self::AccountError { code, message } => Self::AccountError {
774                code,
775                message: with_context(message),
776            },
777            Self::ApiError { code, message } => Self::ApiError {
778                code,
779                message: with_context(message),
780            },
781            Self::RateLimitError { code, message } => Self::RateLimitError {
782                code,
783                message: with_context(message),
784            },
785            Self::ContentPolicyError { code, message } => Self::ContentPolicyError {
786                code,
787                message: with_context(message),
788            },
789            Self::FileError { code, message } => Self::FileError {
790                code,
791                message: with_context(message),
792            },
793            // No message slot: keep the wrapped source as-is (context belongs
794            // in a tracing span, not by flattening the source to a string).
795            Self::NetworkError(err) => Self::NetworkError(err),
796            Self::JsonError(err) => Self::JsonError(err),
797            Self::RealtimeError(kind) => Self::RealtimeError(kind),
798            Self::RealtimeAuthError(message) => Self::RealtimeAuthError(with_context(message)),
799            Self::Unknown { code, message } => Self::Unknown {
800                code,
801                message: with_context(message),
802            },
803        }
804    }
805}
806
807/// Type alias for Result with ZaiError
808pub type ZaiResult<T> = Result<T, ZaiError>;
809
810/// Convert from reqwest::Error to ZaiError
811impl From<reqwest::Error> for ZaiError {
812    fn from(err: reqwest::Error) -> Self {
813        if let Some(status) = err.status() {
814            ZaiError::from_api_response(status.as_u16(), 0, err.to_string())
815        } else {
816            ZaiError::NetworkError(Arc::new(err))
817        }
818    }
819}
820
821/// Convert from serde_json::Error to ZaiError
822impl From<serde_json::Error> for ZaiError {
823    fn from(err: serde_json::Error) -> Self {
824        ZaiError::JsonError(Arc::new(err))
825    }
826}
827
828/// Convert from validator::ValidationErrors to ZaiError
829impl From<validator::ValidationErrors> for ZaiError {
830    fn from(err: validator::ValidationErrors) -> Self {
831        ZaiError::ApiError {
832            code: codes::SDK_VALIDATION,
833            message: format!("Validation error: {err:?}"),
834        }
835    }
836}
837
838/// Convert from std::io::Error to ZaiError.
839///
840/// Maps by [`std::io::ErrorKind`] so the category (file vs. timeout vs.
841/// generic I/O) survives propagation instead of collapsing to a single
842/// opaque `Unknown{0}`. A `NetworkError` cannot be built from an
843/// `io::Error` (it wraps `reqwest::Error`), so `TimedOut` is reported as an
844/// [`ApiError`](Self::ApiError) carrying [`codes::SDK_TIMEOUT`].
845impl From<std::io::Error> for ZaiError {
846    fn from(err: std::io::Error) -> Self {
847        use std::io::ErrorKind;
848        match err.kind() {
849            ErrorKind::NotFound => ZaiError::FileError {
850                code: codes::SDK_FILE_NOT_FOUND,
851                message: err.to_string(),
852            },
853            ErrorKind::PermissionDenied => ZaiError::FileError {
854                code: codes::SDK_IO,
855                message: err.to_string(),
856            },
857            ErrorKind::TimedOut => ZaiError::ApiError {
858                code: codes::SDK_TIMEOUT,
859                message: err.to_string(),
860            },
861            _ => ZaiError::Unknown {
862                code: codes::SDK_IO,
863                message: err.to_string(),
864            },
865        }
866    }
867}
868
869/// Convert from a realtime transport error kind into a [`ZaiError`].
870impl From<RealtimeErrorKind> for ZaiError {
871    fn from(kind: RealtimeErrorKind) -> Self {
872        ZaiError::RealtimeError(Arc::new(kind))
873    }
874}
875
876/// Convert from a low-level WebSocket (`tungstenite`) error into a
877/// [`ZaiError`]. The original error is preserved as the `#[source]` of
878/// [`RealtimeErrorKind::WebSocket`]. Only available with the `realtime`
879/// feature.
880#[cfg(feature = "realtime")]
881impl From<tokio_tungstenite::tungstenite::Error> for ZaiError {
882    fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
883        ZaiError::RealtimeError(Arc::new(RealtimeErrorKind::WebSocket { source: err }))
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    // `super::*` would otherwise pull in the `thiserror::Error` derive macro as
891    // `Error`; bring `std::io`'s `Error`/`ErrorKind` into scope for the io-Error
892    // conversion tests below.
893    use std::io::{Error, ErrorKind};
894
895    #[test]
896    fn test_from_api_response_bad_request() {
897        let err = ZaiError::from_api_response(400, 0, "Invalid input".to_string());
898        assert!(err.is_client_error());
899        assert!(!err.is_server_error());
900        assert_eq!(err.code(), Some(400));
901    }
902
903    #[test]
904    fn test_from_api_response_unauthorized() {
905        let err = ZaiError::from_api_response(401, 0, "".to_string());
906        assert!(err.is_client_error());
907        assert_eq!(err.message(), "Unauthorized - check your API key");
908    }
909
910    #[test]
911    fn test_from_api_response_rate_limit() {
912        // Business code takes precedence over HTTP status.
913        let err = ZaiError::from_api_response(429, 1302, "Too many requests".to_string());
914        assert!(err.is_client_error());
915        assert!(err.is_rate_limit());
916        assert_eq!(err.code(), Some(1302));
917
918        // API code 1302 returns RateLimitError even with a non-error HTTP
919        // status.
920        let err = ZaiError::from_api_response(200, 1302, "Too many requests".to_string());
921        assert!(err.is_client_error());
922        assert!(err.is_rate_limit());
923        assert_eq!(err.code(), Some(1302));
924    }
925
926    #[test]
927    fn test_from_api_response_package_limit_codes() {
928        for code in [1302, 1303, 1304, 1305, 1308, 1309, 1310, 1311, 1312, 1313] {
929            let err = ZaiError::from_api_response(429, code, "Limited".to_string());
930            assert!(err.is_rate_limit());
931            assert_eq!(err.code(), Some(code));
932        }
933    }
934
935    #[test]
936    fn test_from_api_response_content_policy_codes() {
937        for code in [1300, 1301] {
938            let err = ZaiError::from_api_response(400, code, "Blocked".to_string());
939            assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
940            assert!(err.is_client_error());
941            assert!(!err.is_rate_limit());
942            assert_eq!(err.code(), Some(code));
943        }
944    }
945
946    #[test]
947    fn test_from_api_response_server_error() {
948        let err = ZaiError::from_api_response(500, 0, "".to_string());
949        assert!(!err.is_client_error());
950        assert!(err.is_server_error());
951    }
952
953    #[test]
954    fn test_from_api_response_auth_error_code() {
955        let err = ZaiError::from_api_response(200, 1001, "Invalid API key".to_string());
956        assert!(err.is_auth_error());
957        assert_eq!(err.code(), Some(1001));
958        assert_eq!(err.message(), "Invalid API key");
959    }
960
961    #[test]
962    fn test_from_api_response_account_error() {
963        let err = ZaiError::from_api_response(200, 1110, "Account expired".to_string());
964        assert!(err.is_client_error());
965        assert_eq!(err.code(), Some(1110));
966    }
967
968    #[test]
969    fn test_from_api_response_api_error() {
970        let err = ZaiError::from_api_response(200, 1200, "Invalid parameters".to_string());
971        assert!(err.is_client_error());
972        assert_eq!(err.code(), Some(1200));
973    }
974
975    #[test]
976    fn test_from_api_response_unknown_code() {
977        let err = ZaiError::from_api_response(200, 9999, "Unknown error".to_string());
978        assert!(!err.is_client_error()); // Unknown code doesn't mean client error
979        assert_eq!(err.code(), Some(9999));
980    }
981
982    #[test]
983    fn test_compact() {
984        let err = ZaiError::HttpError {
985            status: 404,
986            message: "Not found".to_string(),
987        };
988        assert_eq!(err.compact(), "HTTP[404]: Not found");
989
990        let err = ZaiError::AuthError {
991            code: 1001,
992            message: "Invalid key".to_string(),
993        };
994        assert_eq!(err.compact(), "AUTH[1001]: Invalid key");
995    }
996
997    #[test]
998    fn test_code() {
999        // Using From trait implementation for io::Error: ErrorKind::ConnectionRefused
1000        // is not NotFound/PermissionDenied/TimedOut, so it falls through to
1001        // Unknown carrying the SDK I/O code.
1002        let io_err = Error::new(ErrorKind::ConnectionRefused, "connection refused");
1003        let err = ZaiError::from(io_err);
1004        assert_eq!(err.code(), Some(codes::SDK_IO));
1005
1006        // JsonError has no code
1007        let err = ZaiError::JsonError(Arc::new(serde_json::Error::io(Error::new(
1008            ErrorKind::InvalidData,
1009            "invalid JSON",
1010        ))));
1011        assert!(err.code().is_none());
1012
1013        // HttpError has status as code
1014        let err = ZaiError::HttpError {
1015            status: 500,
1016            message: "Server error".to_string(),
1017        };
1018        assert_eq!(err.code(), Some(500));
1019    }
1020
1021    #[test]
1022    fn test_message() {
1023        let err = ZaiError::RateLimitError {
1024            code: 1302,
1025            message: "Too many requests".to_string(),
1026        };
1027        assert_eq!(err.message(), "Too many requests");
1028    }
1029
1030    #[test]
1031    fn test_from_reqwest_error_with_status() {
1032        let io_err = Error::other("test error");
1033        let zai_err = ZaiError::from(io_err);
1034        match zai_err {
1035            ZaiError::Unknown { .. } => {},
1036            _ => panic!("Expected Unknown error for io::Error"),
1037        }
1038    }
1039
1040    #[test]
1041    fn test_sdk_code_constants_in_reserved_range() {
1042        for code in [
1043            codes::SDK_VALIDATION,
1044            codes::SDK_CONFIG,
1045            codes::SDK_FILE_NOT_FOUND,
1046            codes::SDK_FILE_TOO_LARGE,
1047            codes::SDK_FILE_TYPE_UNSUPPORTED,
1048            codes::SDK_IO,
1049            codes::SDK_TIMEOUT,
1050            codes::SDK_EXTERNAL_TOOL,
1051        ] {
1052            assert!(
1053                (9000..=9999).contains(&code),
1054                "code {code} outside 9000-9999"
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn test_is_sdk_error_classification() {
1061        // SDK codes → true.
1062        assert!(
1063            ZaiError::FileError {
1064                code: codes::SDK_FILE_NOT_FOUND,
1065                message: "x".into(),
1066            }
1067            .is_sdk_error()
1068        );
1069        assert!(
1070            ZaiError::ApiError {
1071                code: codes::SDK_TIMEOUT,
1072                message: "x".into(),
1073            }
1074            .is_sdk_error()
1075        );
1076
1077        // API / HTTP codes → false.
1078        assert!(
1079            !ZaiError::AuthError {
1080                code: 1001,
1081                message: "x".into(),
1082            }
1083            .is_sdk_error()
1084        );
1085        assert!(
1086            !ZaiError::RateLimitError {
1087                code: 1302,
1088                message: "x".into(),
1089            }
1090            .is_sdk_error()
1091        );
1092        assert!(
1093            !ZaiError::HttpError {
1094                status: 500,
1095                message: "x".into(),
1096            }
1097            .is_sdk_error()
1098        );
1099
1100        // Code-less variants → false.
1101        assert!(!ZaiError::RealtimeAuthError("x".into()).is_sdk_error());
1102    }
1103
1104    #[test]
1105    fn test_from_io_maps_by_kind() {
1106        use std::io::{Error, ErrorKind};
1107
1108        let err = ZaiError::from(Error::from(ErrorKind::NotFound));
1109        assert!(matches!(
1110            err,
1111            ZaiError::FileError { code, .. } if code == codes::SDK_FILE_NOT_FOUND
1112        ));
1113
1114        let err = ZaiError::from(Error::from(ErrorKind::TimedOut));
1115        assert!(matches!(
1116            err,
1117            ZaiError::ApiError { code, .. } if code == codes::SDK_TIMEOUT
1118        ));
1119
1120        let err = ZaiError::from(Error::from(ErrorKind::PermissionDenied));
1121        assert!(matches!(
1122            err,
1123            ZaiError::FileError { code, .. } if code == codes::SDK_IO
1124        ));
1125
1126        // Unmapped kind → Unknown with SDK_IO code (no longer code 0).
1127        let err = ZaiError::from(Error::other("boom"));
1128        assert!(matches!(
1129            err,
1130            ZaiError::Unknown { code, .. } if code == codes::SDK_IO
1131        ));
1132    }
1133
1134    #[test]
1135    fn test_context_preserves_code_and_variant() {
1136        let err = ZaiError::ApiError {
1137            code: 1200,
1138            message: "bad model".into(),
1139        }
1140        .context("file parser create");
1141        assert!(matches!(
1142            err,
1143            ZaiError::ApiError { code, .. } if code == 1200
1144        ));
1145        assert_eq!(err.message(), "file parser create: bad model");
1146
1147        let err = ZaiError::Unknown {
1148            code: codes::SDK_IO,
1149            message: "boom".into(),
1150        }
1151        .context("read");
1152        assert_eq!(err.code(), Some(codes::SDK_IO));
1153        assert_eq!(err.message(), "read: boom");
1154    }
1155
1156    #[test]
1157    fn test_sdk_timeout_is_not_rate_limit() {
1158        // Regression guard: a client-side polling timeout must NOT masquerade
1159        // as a rate-limit error (the previous implementation returned
1160        // RateLimitError{code:0}).
1161        let err = ZaiError::ApiError {
1162            code: codes::SDK_TIMEOUT,
1163            message: "Timeout waiting for parsing result".into(),
1164        };
1165        assert!(!err.is_rate_limit());
1166        assert!(err.is_sdk_error());
1167    }
1168
1169    #[test]
1170    fn test_validate_api_key_valid() {
1171        assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
1172    }
1173
1174    #[test]
1175    fn test_validate_api_key_empty() {
1176        let result = validate_api_key("");
1177        assert!(result.is_err());
1178        match result {
1179            Err(ZaiError::ApiError { code, .. }) => {
1180                assert_eq!(code, codes::SDK_VALIDATION);
1181            },
1182            _ => panic!("Expected ApiError"),
1183        }
1184    }
1185
1186    #[test]
1187    fn test_validate_api_key_no_dot() {
1188        let result = validate_api_key("invalid");
1189        assert!(result.is_err());
1190        match result {
1191            Err(ZaiError::ApiError { code, message }) => {
1192                assert_eq!(code, codes::SDK_VALIDATION);
1193                assert!(message.contains("format"));
1194            },
1195            _ => panic!("Expected ApiError"),
1196        }
1197    }
1198
1199    #[test]
1200    fn test_validate_api_key_multiple_dots() {
1201        let result = validate_api_key("id.secret.extra");
1202        assert!(result.is_err());
1203        assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1204    }
1205
1206    #[test]
1207    fn test_validate_api_key_empty_id() {
1208        let result = validate_api_key(".secret123456789");
1209        assert!(result.is_err());
1210        assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1211    }
1212
1213    #[test]
1214    fn test_validate_api_key_empty_secret() {
1215        let result = validate_api_key("id123.");
1216        assert!(result.is_err());
1217        assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1218    }
1219
1220    #[test]
1221    fn test_validate_api_key_invalid_chars() {
1222        let result = validate_api_key("id$123.secret@456");
1223        assert!(result.is_err());
1224        assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1225    }
1226
1227    #[test]
1228    fn test_validate_api_key_id_too_short() {
1229        let result = validate_api_key("ab.abcdefghijklmn");
1230        assert!(result.is_err());
1231        assert!(result.unwrap_err().message().contains("id is too short"));
1232    }
1233
1234    #[test]
1235    fn test_validate_api_key_secret_too_short() {
1236        let result = validate_api_key("id123.short");
1237        assert!(result.is_err());
1238        assert!(
1239            result
1240                .unwrap_err()
1241                .message()
1242                .contains("secret is too short")
1243        );
1244    }
1245
1246    #[test]
1247    fn test_mask_sensitive_info_api_key() {
1248        let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1249        let filtered = mask_sensitive_info(text);
1250        assert!(filtered.contains("[FILTERED]"));
1251        assert!(!filtered.contains("abc123"));
1252        assert!(!filtered.contains("abcdefghijklmnopqrstuvwxyz"));
1253    }
1254
1255    #[test]
1256    fn test_mask_sensitive_info_password() {
1257        let text = "password: secret123, other text";
1258        let filtered = mask_sensitive_info(text);
1259        assert!(filtered.contains("[FILTERED]"));
1260        assert!(!filtered.contains("secret123"));
1261    }
1262
1263    #[test]
1264    fn test_mask_sensitive_info_token() {
1265        let text = "token=abc123xyz, other content";
1266        let filtered = mask_sensitive_info(text);
1267        assert!(filtered.contains("[FILTERED]"));
1268        assert!(!filtered.contains("abc123xyz"));
1269    }
1270
1271    #[test]
1272    fn test_mask_sensitive_info_bearer() {
1273        let text = "Authorization: Bearer abc123.abc1234567890";
1274        let filtered = mask_sensitive_info(text);
1275        // The whole Authorization header is replaced, including the header name
1276        // and Bearer scheme.
1277        assert!(filtered.contains("[AUTH_REDACTED]"));
1278        assert!(!filtered.contains("abc123"));
1279        assert!(!filtered.contains("Bearer"));
1280        assert!(!filtered.contains("Authorization"));
1281    }
1282
1283    #[test]
1284    fn test_mask_sensitive_info_multiple() {
1285        let text = "api_key=abc123.xyz456, password=secret123";
1286        let filtered = mask_sensitive_info(text);
1287        let filtered_count = filtered.matches("[FILTERED]").count();
1288        assert_eq!(filtered_count, 2);
1289    }
1290
1291    #[test]
1292    fn test_mask_sensitive_info_no_sensitive() {
1293        let text = "Regular text without sensitive information";
1294        let filtered = mask_sensitive_info(text);
1295        assert_eq!(filtered, text);
1296    }
1297
1298    #[test]
1299    fn test_mask_api_key() {
1300        let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1301        let filtered = mask_api_key(text);
1302        assert!(filtered.contains("[FILTERED]"));
1303        assert!(!filtered.contains("abc123"));
1304    }
1305
1306    #[test]
1307    fn test_contains_sensitive_info_api_key() {
1308        assert!(contains_sensitive_info("api_key: abc123.abc1234567890"));
1309        assert!(!contains_sensitive_info("regular text"));
1310    }
1311
1312    #[test]
1313    fn test_contains_sensitive_info_password() {
1314        assert!(contains_sensitive_info("password: secret"));
1315        assert!(contains_sensitive_info("password=123"));
1316        assert!(!contains_sensitive_info("password"));
1317        assert!(!contains_sensitive_info("word:password"));
1318    }
1319
1320    #[test]
1321    fn test_contains_sensitive_info_token() {
1322        assert!(contains_sensitive_info("token=abc123"));
1323        assert!(contains_sensitive_info("token: xyz123"));
1324        assert!(!contains_sensitive_info("token"));
1325        assert!(!contains_sensitive_info("tokenize this"));
1326    }
1327
1328    #[test]
1329    fn test_error_category_classification() {
1330        // Single source of truth: `category()` drives is_client_error /
1331        // is_server_error / is_retryable. Spot-check the classification table.
1332
1333        // Rate-limit business error: client-side AND retryable.
1334        let rl = ZaiError::RateLimitError {
1335            code: 1302,
1336            message: "slow down".into(),
1337        };
1338        assert_eq!(rl.category(), ErrorCategory::RateLimit);
1339        assert!(rl.is_retryable());
1340        assert!(rl.is_client_error());
1341        assert!(!rl.is_server_error());
1342
1343        // HTTP 429 -> rate limit, retryable, client-side.
1344        let h429 = ZaiError::HttpError {
1345            status: 429,
1346            message: "too many".into(),
1347        };
1348        assert_eq!(h429.category(), ErrorCategory::RateLimit);
1349        assert!(h429.is_retryable());
1350        assert!(h429.is_client_error());
1351
1352        // HTTP 500 -> server, retryable, not client.
1353        let h500 = ZaiError::HttpError {
1354            status: 500,
1355            message: "boom".into(),
1356        };
1357        assert_eq!(h500.category(), ErrorCategory::Server);
1358        assert!(h500.is_retryable());
1359        assert!(h500.is_server_error());
1360        assert!(!h500.is_client_error());
1361
1362        // HTTP 400 -> client, NOT retryable.
1363        let h400 = ZaiError::HttpError {
1364            status: 400,
1365            message: "bad".into(),
1366        };
1367        assert_eq!(h400.category(), ErrorCategory::Client);
1368        assert!(!h400.is_retryable());
1369        assert!(h400.is_client_error());
1370
1371        // Auth -> client-side, not retryable.
1372        let auth = ZaiError::AuthError {
1373            code: 1001,
1374            message: "bad key".into(),
1375        };
1376        assert_eq!(auth.category(), ErrorCategory::Auth);
1377        assert!(!auth.is_retryable());
1378        assert!(auth.is_client_error());
1379
1380        // Unknown 5xx is reported as a server error but, by design, is NOT
1381        // retried (its transience is uncertain) — this is the one intentional
1382        // divergence between `is_server_error` and `is_retryable`.
1383        let unk = ZaiError::Unknown {
1384            code: 503,
1385            message: "?".into(),
1386        };
1387        assert_eq!(unk.category(), ErrorCategory::Server);
1388        assert!(unk.is_server_error());
1389        assert!(!unk.is_retryable());
1390    }
1391
1392    #[test]
1393    fn test_business_code_band_boundaries() {
1394        // 1306/1307 sit in the unmapped gap between content-policy
1395        // (1300-1301) and rate-limit (1308-1313): they must classify as
1396        // Unknown, NOT RateLimitError.
1397        for code in [1306, 1307] {
1398            let e = ZaiError::from_api_response(400, code, "gap".to_string());
1399            assert!(
1400                matches!(e, ZaiError::Unknown { .. }),
1401                "code {code} -> Unknown"
1402            );
1403            assert!(!e.is_rate_limit());
1404        }
1405        // 1499 is the inclusive top of the FileError band (1400-1499).
1406        let e = ZaiError::from_api_response(400, 1499, "file".to_string());
1407        assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
1408        // 1400 is the bottom of the FileError band.
1409        let e = ZaiError::from_api_response(400, 1400, "file".to_string());
1410        assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
1411        // The rate-limit band edges (1302, 1305, 1308, 1313) are rate-limit.
1412        for code in [1302, 1305, 1308, 1313] {
1413            let e = ZaiError::from_api_response(429, code, "rl".to_string());
1414            assert!(e.is_rate_limit(), "code {code} -> RateLimitError");
1415        }
1416    }
1417
1418    // ----- HTTP status classification for status-only responses -----
1419
1420    #[test]
1421    fn status_502_503_504_classify_as_server_and_carry_status() {
1422        // Gateway/server failures retain their real status and classify as
1423        // server-side and retryable.
1424        for status in [502, 503, 504] {
1425            let e = ZaiError::from_api_response(status, 0, String::new());
1426            match &e {
1427                ZaiError::HttpError {
1428                    status: s,
1429                    message: _,
1430                } => {
1431                    assert_eq!(*s, status, "HTTP {status} lost its status code");
1432                    assert!(e.is_server_error(), "HTTP {status} not classified Server");
1433                    assert!(
1434                        e.is_retryable(),
1435                        "HTTP {status} should be retryable as a 5xx"
1436                    );
1437                },
1438                other => panic!("HTTP {status} classified as {other:?}, expected HttpError"),
1439            }
1440        }
1441        // 500 stays Server too.
1442        let e = ZaiError::from_api_response(500, 0, String::new());
1443        assert!(matches!(e, ZaiError::HttpError { status: 500, .. }));
1444        assert!(e.is_server_error());
1445    }
1446
1447    #[test]
1448    fn status_401_403_classify_as_auth() {
1449        for status in [401, 403] {
1450            let e = ZaiError::from_api_response(status, 0, String::new());
1451            assert!(
1452                e.is_auth_error(),
1453                "HTTP {status} should classify as auth, got {e:?}"
1454            );
1455            assert!(
1456                e.is_client_error(),
1457                "HTTP {status} should be a client error"
1458            );
1459            assert!(
1460                !e.is_retryable(),
1461                "HTTP {status} (auth) should not be retryable"
1462            );
1463        }
1464    }
1465
1466    #[test]
1467    fn status_429_classifies_as_rate_limit() {
1468        let e = ZaiError::from_api_response(429, 0, String::new());
1469        assert!(
1470            e.is_rate_limit(),
1471            "HTTP 429 should classify as rate limit, got {e:?}"
1472        );
1473        assert!(e.is_retryable(), "HTTP 429 should be retryable");
1474    }
1475}