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