Skip to main content

zai_rs/client/
http.rs

1//! # HTTP Client Implementation
2//!
3//! Provides a robust HTTP client for communicating with the Zhipu AI API.
4//! This module implements connection pooling, error handling, and
5//! request/response processing.
6//!
7//! ## Features
8//!
9//! - Connection Pooling - Reuses HTTP connections for better performance
10//! - Error Handling - Comprehensive error parsing and reporting
11//! - Authentication - Bearer token authentication support
12//! - Retry with Jitter - Automatic retry with exponential backoff and random
13//!   jitter
14//! - Sensitive Data Masking - Automatic masking of API keys in logs
15//! - Structured Logging - Uses tracing for detailed request/response logging
16//!
17//! ## Usage
18//!
19//! The `HttpClient` trait provides a standardized interface for making HTTP
20//! requests to the Zhipu AI API endpoints.
21//!
22//! # Retry Configuration
23//!
24//! The HTTP client supports configurable retry behavior:
25//!
26//! ```ignore
27//! use zai_rs::client::http::HttpClientConfig;
28//!
29//! let config = HttpClientConfig::builder()
30//!     .max_retries(5)
31//!     .timeout(Duration::from_secs(120))
32//!     .retry_delay(RetryDelay::exponential(Duration::from_millis(100), Duration::from_secs(10)))
33//!     .build();
34//! ```
35
36use std::{
37    sync::{Arc, OnceLock},
38    time::Duration,
39};
40
41use reqwest::Method;
42use serde::{Deserialize, de::DeserializeOwned};
43use tracing::{trace, warn};
44
45use crate::client::error::{ZaiError, ZaiResult, mask_sensitive_info};
46
47#[derive(Debug, Deserialize)]
48#[serde(untagged)]
49enum ApiErrorEnvelope {
50    Nested { error: ApiError },
51    Flat { code: ErrorCode, message: String },
52}
53
54impl ApiErrorEnvelope {
55    fn into_parts(self) -> (ErrorCode, String) {
56        match self {
57            ApiErrorEnvelope::Nested { error } => (error.code, error.message),
58            ApiErrorEnvelope::Flat { code, message } => (code, message),
59        }
60    }
61}
62
63#[derive(Debug, Deserialize)]
64
65struct ApiError {
66    code: ErrorCode,
67
68    message: String,
69}
70
71#[derive(Debug, Deserialize)]
72#[serde(untagged)]
73enum ErrorCode {
74    Str(String),
75
76    Num(i64),
77}
78
79impl std::fmt::Display for ErrorCode {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ErrorCode::Str(s) => write!(f, "{}", s),
83
84            ErrorCode::Num(n) => write!(f, "{}", n),
85        }
86    }
87}
88
89fn to_api_code(code: &ErrorCode) -> u16 {
90    match code {
91        ErrorCode::Num(n) => (*n).try_into().unwrap_or(0),
92        ErrorCode::Str(s) => s.parse::<u16>().unwrap_or(0),
93    }
94}
95
96/// Parse an API error response body into a ZaiError.
97///
98/// Attempts to deserialize the body as `{"error":{"code":...,"message":...}}`
99/// and maps it to the appropriate ZaiError variant. Falls back to a generic
100/// HttpError if parsing fails.
101pub fn parse_api_error_response(status: u16, body: String) -> crate::client::error::ZaiError {
102    if let Ok(parsed) = serde_json::from_str::<ApiErrorEnvelope>(&body) {
103        let (code, message) = parsed.into_parts();
104        let api_code = to_api_code(&code);
105        crate::client::error::ZaiError::from_api_response(status, api_code, message)
106    } else {
107        crate::client::error::ZaiError::from_api_response(status, 0, body)
108    }
109}
110
111/// Retry delay strategy.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RetryDelay {
114    /// Fixed delay between retries
115    Fixed(Duration),
116
117    /// Exponential backoff with jitter
118    Exponential { base: Duration, max: Duration },
119
120    /// No delay (not recommended for production)
121    None,
122}
123
124impl RetryDelay {
125    /// Create a fixed delay strategy
126    pub fn fixed(delay: Duration) -> Self {
127        Self::Fixed(delay)
128    }
129
130    /// Create an exponential backoff strategy
131    pub fn exponential(base: Duration, max: Duration) -> Self {
132        Self::Exponential { base, max }
133    }
134
135    /// Create a no-delay strategy (not recommended)
136    pub fn none() -> Self {
137        Self::None
138    }
139}
140
141impl Default for RetryDelay {
142    fn default() -> Self {
143        Self::Exponential {
144            base: Duration::from_millis(500),
145            max: Duration::from_secs(5),
146        }
147    }
148}
149
150/// Configuration for HTTP client behavior.
151///
152/// Use the builder pattern for fluent configuration:
153///
154/// ```ignore
155/// use zai_rs::client::http::HttpClientConfig;
156///
157/// let config = HttpClientConfig::builder()
158///     .max_retries(5)
159///     .timeout(Duration::from_secs(120))
160///     .retry_delay(RetryDelay::exponential(Duration::from_millis(100), Duration::from_secs(10)))
161///     .enable_logging(true)
162///     .build();
163/// ```
164#[derive(Debug, Clone)]
165pub struct HttpClientConfig {
166    /// Request timeout duration (default: 60 seconds)
167    pub timeout: Duration,
168
169    /// Maximum number of retry attempts (default: 3)
170    pub max_retries: u32,
171
172    /// Enable gzip compression (default: true)
173    pub enable_compression: bool,
174
175    /// Retry delay strategy
176    pub retry_delay: RetryDelay,
177
178    /// Enable detailed logging (default: false).
179    ///
180    /// **Note:** This field is retained for API stability but is now a no-op.
181    /// The transport pipeline always emits a masked `trace!` line for the
182    /// outbound/inbound body (observable with `RUST_LOG=trace`), and `warn!`
183    /// on retries — per the library-silent logging policy, the success path
184    /// produces no `info!` output.
185    pub enable_logging: bool,
186
187    /// Enable sensitive data masking in logs (default: true)
188    pub mask_sensitive_data: bool,
189}
190
191impl Default for HttpClientConfig {
192    fn default() -> Self {
193        Self {
194            timeout: Duration::from_secs(60),
195            max_retries: 3,
196            enable_compression: true,
197            retry_delay: RetryDelay::default(),
198            enable_logging: false,
199            mask_sensitive_data: true,
200        }
201    }
202}
203
204impl HttpClientConfig {
205    /// Create a new builder for fluent configuration
206    pub fn builder() -> HttpClientConfigBuilder {
207        HttpClientConfigBuilder::new()
208    }
209}
210
211/// Builder for creating `HttpClientConfig` instances.
212///
213/// Provides a fluent API for configuring HTTP client behavior.
214///
215/// # Example
216///
217/// ```ignore
218/// use zai_rs::client::http::HttpClientConfig;
219///
220/// let config = HttpClientConfig::builder()
221///     .max_retries(5)
222///     .timeout(Duration::from_secs(120))
223///     .retry_delay(RetryDelay::exponential(Duration::from_millis(100), Duration::from_secs(10)))
224///     .build();
225/// ```
226pub struct HttpClientConfigBuilder {
227    config: HttpClientConfig,
228}
229
230impl HttpClientConfigBuilder {
231    /// Create a new builder with default configuration
232    pub fn new() -> Self {
233        Self {
234            config: HttpClientConfig::default(),
235        }
236    }
237
238    /// Set the request timeout duration
239    pub fn timeout(mut self, timeout: Duration) -> Self {
240        self.config.timeout = timeout;
241        self
242    }
243
244    /// Set the maximum number of retry attempts
245    pub fn max_retries(mut self, max_retries: u32) -> Self {
246        self.config.max_retries = max_retries;
247        self
248    }
249
250    /// Enable or disable gzip compression
251    pub fn compression(mut self, enable: bool) -> Self {
252        self.config.enable_compression = enable;
253        self
254    }
255
256    /// Set the retry delay strategy
257    pub fn retry_delay(mut self, delay: RetryDelay) -> Self {
258        self.config.retry_delay = delay;
259        self
260    }
261
262    /// Enable or disable detailed logging (retained for API stability; no-op —
263    /// see [`HttpClientConfig::enable_logging`]).
264    pub fn logging(mut self, enable: bool) -> Self {
265        self.config.enable_logging = enable;
266        self
267    }
268
269    /// Enable or disable sensitive data masking in logs
270    pub fn mask_sensitive_data(mut self, enable: bool) -> Self {
271        self.config.mask_sensitive_data = enable;
272        self
273    }
274
275    /// Build the configuration
276    pub fn build(self) -> HttpClientConfig {
277        self.config
278    }
279}
280
281impl Default for HttpClientConfigBuilder {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287/// A global HTTP client registry for connection pooling and configuration
288/// caching.
289static HTTP_CLIENTS: OnceLock<dashmap::DashMap<String, reqwest::Client>> = OnceLock::new();
290
291/// Get or create an HTTP client with the specified configuration
292///
293/// Clients are cached by configuration to allow connection reuse.
294pub fn http_client_with_config(config: &HttpClientConfig) -> reqwest::Client {
295    let config_key = format!(
296        "timeout:{:?}|compression:{}",
297        config.timeout, config.enable_compression
298    );
299
300    let clients = HTTP_CLIENTS.get_or_init(dashmap::DashMap::new);
301
302    clients
303        .entry(config_key)
304        .or_insert_with(|| {
305            let builder = reqwest::Client::builder().timeout(config.timeout);
306
307            // Note: reqwest's gzip/brotli features are not enabled in this
308            // crate's Cargo.toml, so `enable_compression` is currently a
309            // no-op reserved for future use (the field is kept in the public
310            // config surface for API stability). Enable the corresponding
311            // reqwest cargo features and gate on `config.enable_compression`
312            // here to turn it on.
313
314            builder.build().expect("Failed to build reqwest Client")
315        })
316        .clone()
317}
318
319/// Parse a successful HTTP response into a typed value.
320///
321/// The raw response body is captured as text before deserialization so it can
322/// be emitted at `trace` level. This is the single place that surfaces the
323/// received wire payload — the streaming/SSE paths log per-chunk, and the
324/// error path logs the body inline with the retry decision.
325///
326/// The body is run through [`mask_sensitive_info`] before logging so secrets
327/// (API keys, bearer tokens, …) never leak into logs even at `trace` level.
328#[tracing::instrument(
329    name = "http.response",
330    skip_all,
331    fields(
332        otel.name = "http.parse_response",
333        http.url = tracing::field::Empty,
334        http.status_code = tracing::field::Empty,
335    )
336)]
337pub async fn parse_typed_response<T>(resp: reqwest::Response) -> ZaiResult<T>
338where
339    T: DeserializeOwned,
340{
341    let status = resp.status();
342    let url = resp.url().to_string();
343
344    let span = tracing::Span::current();
345    span.record("http.url", url.as_str());
346    span.record("http.status_code", status.as_u16());
347
348    let body = resp.text().await.map_err(ZaiError::from)?;
349
350    trace!(
351        url = %url,
352        http_status = %status,
353        bytes = body.len(),
354        response_body = %mask_sensitive_info(&body),
355        "Received HTTP response body"
356    );
357
358    serde_json::from_str::<T>(&body).map_err(ZaiError::from)
359}
360
361/// Send a JSON request through the shared transport pipeline.
362///
363/// Emits a single always-on **`trace`** line carrying the raw sent JSON body
364/// (masked via [`mask_sensitive_info`]), so the wire payload is observable
365/// with `RUST_LOG=trace`. Per the library-silent logging policy, the success
366/// path produces no higher-level output; only retries are surfaced (`warn!`).
367///
368/// `enable_logging` on [`HttpClientConfig`] is retained for API stability but
369/// no longer adds output — the `trace` line already covers that need.
370#[tracing::instrument(
371    name = "http.request",
372    skip_all,
373    fields(
374        otel.name = "http.send_json",
375        http.method = %method,
376        http.url = tracing::field::Empty,
377    )
378)]
379pub async fn send_json_request<T>(
380    method: Method,
381    url: impl Into<String>,
382    api_key: impl AsRef<str>,
383    body: &T,
384    config: Arc<HttpClientConfig>,
385) -> ZaiResult<reqwest::Response>
386where
387    T: serde::Serialize + ?Sized,
388{
389    let body_compact = serde_json::to_string(body).map_err(|e| ZaiError::JsonError(Arc::new(e)))?;
390    let url_value: String = url.into();
391
392    tracing::Span::current().record("http.url", url_value.as_str());
393
394    // Always-on `trace` line for the raw outbound body (masked). This is what
395    // `RUST_LOG=trace` surfaces so developers can see exactly what is sent.
396    trace!(
397        method = %method,
398        url = %url_value,
399        bytes = body_compact.len(),
400        request_body = %mask_sensitive_info(&body_compact),
401        "Sending HTTP request body"
402    );
403
404    let key = api_key.as_ref().to_string();
405    send_with_retry_factory(&config, move |client| {
406        let builder = client
407            .request(method.clone(), &url_value)
408            .bearer_auth(&key)
409            .header("Content-Type", "application/json")
410            .body(body_compact.clone());
411
412        Ok(builder)
413    })
414    .await
415}
416
417/// Send a request without a JSON body through the shared transport pipeline.
418///
419/// Always emits a `trace` line for the outbound request line (no body to log),
420/// so GET/DELETE traffic is observable with `RUST_LOG=trace`.
421#[tracing::instrument(
422    name = "http.request",
423    skip_all,
424    fields(
425        otel.name = "http.send_empty",
426        http.method = %method,
427        http.url = tracing::field::Empty,
428    )
429)]
430pub async fn send_empty_request(
431    method: Method,
432    url: impl Into<String>,
433    api_key: impl AsRef<str>,
434    config: Arc<HttpClientConfig>,
435) -> ZaiResult<reqwest::Response> {
436    let url_value: String = url.into();
437    tracing::Span::current().record("http.url", url_value.as_str());
438    trace!(
439        method = %method,
440        url = %url_value,
441        request_body = %"",
442        "Sending HTTP request (no body)"
443    );
444    let key = api_key.as_ref().to_string();
445    send_with_retry_factory(&config, move |client| {
446        Ok(client.request(method.clone(), &url_value).bearer_auth(&key))
447    })
448    .await
449}
450
451/// Send a multipart/form-data request through the shared transport pipeline.
452///
453/// The form is built per attempt so retry can safely recreate multipart bodies.
454///
455/// Always emits a `trace` line for the outbound request metadata (multipart
456/// bodies are not serialized as JSON, so only the request line is logged).
457#[tracing::instrument(
458    name = "http.request",
459    skip_all,
460    fields(
461        otel.name = "http.send_multipart",
462        http.method = %method,
463        http.url = tracing::field::Empty,
464    )
465)]
466pub async fn send_multipart_request<F>(
467    method: Method,
468    url: impl Into<String>,
469    api_key: impl AsRef<str>,
470    config: Arc<HttpClientConfig>,
471    mut build_form: F,
472) -> ZaiResult<reqwest::Response>
473where
474    F: FnMut() -> ZaiResult<reqwest::multipart::Form> + Send,
475{
476    let url_value: String = url.into();
477    tracing::Span::current().record("http.url", url_value.as_str());
478    trace!(
479        method = %method,
480        url = %url_value,
481        "Sending multipart HTTP request"
482    );
483    let key = api_key.as_ref().to_string();
484    send_with_retry_factory(&config, move |client| {
485        let form = build_form()?;
486        Ok(client
487            .request(method.clone(), &url_value)
488            .bearer_auth(&key)
489            .multipart(form))
490    })
491    .await
492}
493
494/// Trait for HTTP clients that communicate with the Zhipu AI API.
495pub trait HttpClient {
496    type Body: serde::Serialize;
497    type ApiUrl: AsRef<str>;
498    type ApiKey: AsRef<str>;
499
500    fn api_url(&self) -> &Self::ApiUrl;
501    fn api_key(&self) -> &Self::ApiKey;
502    fn body(&self) -> &Self::Body;
503
504    /// Get HTTP client configuration for this request
505    ///
506    /// Override this method to provide custom configuration.
507    /// Default implementation returns default configuration.
508    fn http_config(&self) -> Arc<HttpClientConfig> {
509        static DEFAULT: std::sync::OnceLock<Arc<HttpClientConfig>> = std::sync::OnceLock::new();
510        DEFAULT
511            .get_or_init(|| Arc::new(HttpClientConfig::default()))
512            .clone()
513    }
514
515    /// Sends a POST request to the API endpoint.
516    ///
517    /// This method implements retry logic with exponential backoff and jitter.
518    /// It supports configuration through `http_config` method.
519    fn post(&self) -> impl std::future::Future<Output = ZaiResult<reqwest::Response>> + Send {
520        let config = self.http_config().clone();
521        let url = self.api_url().as_ref().to_owned();
522        let key = self.api_key().as_ref().to_owned();
523        let body = serde_json::to_value(self.body()).map_err(|e| ZaiError::JsonError(Arc::new(e)));
524
525        async move {
526            let body = body?;
527            send_json_request(Method::POST, url, key, &body, config).await
528        }
529    }
530
531    /// Sends a GET request to the API endpoint.
532    ///
533    /// This method implements retry logic with exponential backoff and jitter.
534    /// It supports configuration through the `http_config` method.
535    fn get(&self) -> impl std::future::Future<Output = ZaiResult<reqwest::Response>> + Send {
536        let config = self.http_config().clone();
537        let url = self.api_url().as_ref().to_owned();
538        let key = self.api_key().as_ref().to_owned();
539
540        async move { send_empty_request(Method::GET, url, key, config).await }
541    }
542
543    /// Sends a PUT request with a JSON body to the API endpoint.
544    fn put(&self) -> impl std::future::Future<Output = ZaiResult<reqwest::Response>> + Send {
545        let config = self.http_config().clone();
546        let url = self.api_url().as_ref().to_owned();
547        let key = self.api_key().as_ref().to_owned();
548        let body = serde_json::to_value(self.body()).map_err(|e| ZaiError::JsonError(Arc::new(e)));
549
550        async move {
551            let body = body?;
552            send_json_request(Method::PUT, url, key, &body, config).await
553        }
554    }
555
556    /// Sends a DELETE request without a body to the API endpoint.
557    fn delete(&self) -> impl std::future::Future<Output = ZaiResult<reqwest::Response>> + Send {
558        let config = self.http_config().clone();
559        let url = self.api_url().as_ref().to_owned();
560        let key = self.api_key().as_ref().to_owned();
561
562        async move { send_empty_request(Method::DELETE, url, key, config).await }
563    }
564}
565
566/// Internal helper: executes retryable request builders.
567#[tracing::instrument(
568    skip(config, build_request),
569    fields(max_retries = config.max_retries, attempt = tracing::field::Empty)
570)]
571async fn send_with_retry_factory<F>(
572    config: &HttpClientConfig,
573    mut build_request: F,
574) -> ZaiResult<reqwest::Response>
575where
576    F: FnMut(reqwest::Client) -> ZaiResult<reqwest::RequestBuilder>,
577{
578    let mut last_error: Option<ZaiError> = None;
579    let client = http_client_with_config(config);
580
581    for attempt in 0..=config.max_retries {
582        tracing::Span::current().record("attempt", attempt);
583        let resp = match build_request(client.clone()) {
584            Ok(builder) => builder.send().await,
585            Err(error) => return Err(error),
586        };
587
588        match resp {
589            Ok(resp) => {
590                let status = resp.status();
591                let url = resp.url().to_string();
592
593                if status.is_success() {
594                    trace!(http_status = %status, url = %url, "Request succeeded");
595                    return Ok(resp);
596                }
597
598                let text = resp.text().await.unwrap_or_default();
599                let error = parse_api_error_response(status.as_u16(), text);
600
601                if should_retry(&error, attempt, config.max_retries) {
602                    last_error = Some(error.clone());
603                    let delay = calculate_retry_delay(attempt, &config.retry_delay);
604                    let delay_with_jitter = add_jitter(delay);
605                    warn!(
606                        url = %url,
607                        http_status = %status,
608                        attempt = attempt + 1,
609                        max_attempts = config.max_retries + 1,
610                        retry_delay = ?delay_with_jitter,
611                        error = %error.compact(),
612                        "Request failed, retrying"
613                    );
614                    tokio::time::sleep(delay_with_jitter).await;
615                } else {
616                    return Err(error);
617                }
618            },
619            Err(e) => {
620                let url = e.url().map(|u| u.to_string()).unwrap_or_default();
621                let error = ZaiError::from(e);
622
623                if should_retry(&error, attempt, config.max_retries) {
624                    last_error = Some(error.clone());
625                    let delay = calculate_retry_delay(attempt, &config.retry_delay);
626                    let delay_with_jitter = add_jitter(delay);
627                    warn!(
628                        url = %url,
629                        attempt = attempt + 1,
630                        max_attempts = config.max_retries + 1,
631                        retry_delay = ?delay_with_jitter,
632                        error = %error.compact(),
633                        "Request failed, retrying"
634                    );
635                    tokio::time::sleep(delay_with_jitter).await;
636                } else {
637                    return Err(error);
638                }
639            },
640        }
641    }
642
643    let final_err = last_error.unwrap_or_else(|| ZaiError::HttpError {
644        status: 500,
645        message: "Unknown error after retries".to_string(),
646    });
647    warn!(
648        code = ?final_err.code(),
649        error = %final_err.compact(),
650        "Request failed after all retries"
651    );
652    Err(final_err)
653}
654
655/// Calculate delay for a retry attempt based on retry delay strategy.
656fn calculate_retry_delay(attempt: u32, strategy: &RetryDelay) -> Duration {
657    match strategy {
658        RetryDelay::Fixed(delay) => *delay,
659        RetryDelay::Exponential { base, max } => {
660            let delay = *base * 2u32.pow(attempt.min(10));
661            delay.min(*max)
662        },
663        RetryDelay::None => Duration::ZERO,
664    }
665}
666
667/// Determines if an error should trigger a retry.
668fn should_retry(error: &ZaiError, attempt: u32, max_retries: u32) -> bool {
669    if attempt >= max_retries {
670        return false;
671    }
672
673    match error {
674        // Retry on server errors (5xx) and HTTP-level rate limiting.
675        ZaiError::HttpError { status, .. } => *status == 429 || (500..600).contains(status),
676        // Retry on rate limit errors (business codes 1300-1313)
677        ZaiError::RateLimitError { .. } => true,
678        // Retry on network errors
679        ZaiError::NetworkError(_) => true,
680        // Don't retry on client errors (4xx), auth errors, account errors, etc.
681        _ => false,
682    }
683}
684
685/// Adds jitter to delay to avoid thundering herd.
686fn add_jitter(delay: Duration) -> Duration {
687    let jitter_ms = fastrand::u64(0..=delay.as_millis() as u64 / 4);
688    delay + Duration::from_millis(jitter_ms)
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn test_error_code_display_num() {
697        let code = ErrorCode::Num(123);
698        assert_eq!(format!("{}", code), "123");
699    }
700
701    #[test]
702    fn test_error_code_display_str() {
703        let code = ErrorCode::Str("auth_error".to_string());
704        assert_eq!(format!("{}", code), "auth_error");
705    }
706
707    #[test]
708    fn test_to_api_code_num() {
709        let code = ErrorCode::Num(401);
710        assert_eq!(to_api_code(&code), 401);
711    }
712
713    #[test]
714    fn test_to_api_code_str_valid() {
715        let code = ErrorCode::Str("429".to_string());
716        assert_eq!(to_api_code(&code), 429);
717    }
718
719    #[test]
720    fn test_to_api_code_str_invalid() {
721        let code = ErrorCode::Str("invalid".to_string());
722        assert_eq!(to_api_code(&code), 0);
723    }
724
725    #[test]
726    fn test_to_api_code_num_overflow() {
727        let code = ErrorCode::Num(99999);
728        assert_eq!(to_api_code(&code), 0);
729    }
730
731    #[test]
732    fn test_api_error_envelope_deserialize() {
733        let json = r#"{"error":{"code":401,"message":"Unauthorized"}}"#;
734        let envelope: ApiErrorEnvelope = serde_json::from_str(json).unwrap();
735        let (_code, message) = envelope.into_parts();
736        assert_eq!(message, "Unauthorized");
737    }
738
739    #[test]
740    fn test_api_error_envelope_deserialize_str_code() {
741        let json = r#"{"error":{"code":"1300","message":"Rate limit exceeded"}}"#;
742        let envelope: ApiErrorEnvelope = serde_json::from_str(json).unwrap();
743        let (code, message) = envelope.into_parts();
744        assert_eq!(message, "Rate limit exceeded");
745        assert_eq!(to_api_code(&code), 1300);
746    }
747
748    #[test]
749    fn test_api_error_envelope_deserialize_flat() {
750        let json = r#"{"code":1312,"message":"Quota exhausted"}"#;
751        let envelope: ApiErrorEnvelope = serde_json::from_str(json).unwrap();
752        let (code, message) = envelope.into_parts();
753        assert_eq!(message, "Quota exhausted");
754        assert_eq!(to_api_code(&code), 1312);
755    }
756
757    #[test]
758    fn test_parse_api_error_response_prefers_business_code() {
759        let error =
760            parse_api_error_response(429, r#"{"code":1312,"message":"Quota exhausted"}"#.into());
761        assert!(matches!(
762            error,
763            ZaiError::RateLimitError {
764                code: 1312,
765                message
766            } if message == "Quota exhausted"
767        ));
768    }
769
770    #[test]
771    fn test_parse_api_error_response_unparseable_body() {
772        let error = parse_api_error_response(500, "not json".to_string());
773        assert!(matches!(
774            error,
775            ZaiError::HttpError {
776                status: 500,
777                message
778            } if message == "Internal server error - try again later"
779        ));
780    }
781
782    #[test]
783    fn test_calculate_retry_delay_fixed() {
784        let delay = Duration::from_secs(2);
785        let strategy = RetryDelay::Fixed(delay);
786        assert_eq!(calculate_retry_delay(0, &strategy), delay);
787        assert_eq!(calculate_retry_delay(1, &strategy), delay);
788        assert_eq!(calculate_retry_delay(5, &strategy), delay);
789    }
790
791    #[test]
792    fn test_calculate_retry_delay_exponential() {
793        let base = Duration::from_millis(500);
794        let max = Duration::from_secs(5);
795        let strategy = RetryDelay::Exponential { base, max };
796
797        assert_eq!(
798            calculate_retry_delay(0, &strategy),
799            Duration::from_millis(500)
800        );
801        assert_eq!(
802            calculate_retry_delay(1, &strategy),
803            Duration::from_millis(1000)
804        );
805        assert_eq!(
806            calculate_retry_delay(2, &strategy),
807            Duration::from_millis(2000)
808        );
809        assert_eq!(
810            calculate_retry_delay(3, &strategy),
811            Duration::from_millis(4000)
812        );
813        assert_eq!(calculate_retry_delay(4, &strategy), max);
814        assert_eq!(calculate_retry_delay(10, &strategy), max);
815    }
816
817    #[test]
818    fn test_calculate_retry_delay_none() {
819        let strategy = RetryDelay::None;
820        assert_eq!(calculate_retry_delay(0, &strategy), Duration::ZERO);
821        assert_eq!(calculate_retry_delay(5, &strategy), Duration::ZERO);
822    }
823
824    #[test]
825    fn test_add_jitter() {
826        let delay = Duration::from_millis(1000);
827        let with_jitter = add_jitter(delay);
828
829        // Jitter should be between 0 and 25% of the delay
830        assert!(with_jitter >= delay);
831        assert!(with_jitter <= delay + Duration::from_millis(250));
832    }
833
834    #[test]
835    fn test_should_retry_server_error() {
836        let error = ZaiError::HttpError {
837            status: 500,
838            message: "Internal server error".to_string(),
839        };
840        assert!(should_retry(&error, 0, 3));
841        assert!(should_retry(&error, 2, 3));
842        assert!(!should_retry(&error, 3, 3));
843    }
844
845    #[test]
846    fn test_should_retry_gateway_timeout() {
847        let error = ZaiError::HttpError {
848            status: 504,
849            message: "Gateway timeout".to_string(),
850        };
851        assert!(should_retry(&error, 0, 3));
852    }
853
854    #[test]
855    fn test_should_retry_rate_limit() {
856        let error = ZaiError::RateLimitError {
857            code: 1301,
858            message: "Rate limit exceeded".to_string(),
859        };
860        assert!(should_retry(&error, 0, 3));
861    }
862
863    #[test]
864    fn test_should_retry_http_429() {
865        let error = ZaiError::HttpError {
866            status: 429,
867            message: "Too many requests".to_string(),
868        };
869        assert!(should_retry(&error, 0, 3));
870    }
871
872    #[test]
873    fn test_should_retry_network_error() {
874        // Since we can't construct reqwest::Error directly in tests,
875        // simulate network error behavior with a 503 status
876        let error = ZaiError::HttpError {
877            status: 503,
878            message: "Network error".to_string(),
879        };
880        assert!(should_retry(&error, 0, 3));
881    }
882
883    #[test]
884    fn test_should_not_retry_client_error() {
885        let error = ZaiError::HttpError {
886            status: 400,
887            message: "Bad request".to_string(),
888        };
889        assert!(!should_retry(&error, 0, 3));
890    }
891
892    #[test]
893    fn test_should_not_retry_unauthorized() {
894        let error = ZaiError::AuthError {
895            code: 1001,
896            message: "Invalid API key".to_string(),
897        };
898        assert!(!should_retry(&error, 0, 3));
899    }
900
901    #[test]
902    fn test_should_not_retry_account_error() {
903        let error = ZaiError::AccountError {
904            code: 1110,
905            message: "Account not found".to_string(),
906        };
907        assert!(!should_retry(&error, 0, 3));
908    }
909
910    #[test]
911    fn test_should_not_retry_not_found() {
912        let error = ZaiError::HttpError {
913            status: 404,
914            message: "Resource not found".to_string(),
915        };
916        assert!(!should_retry(&error, 0, 3));
917    }
918
919    #[test]
920    fn test_should_not_retry_sdk_timeout() {
921        // Regression guard: a client-side polling timeout (SDK_TIMEOUT) must NOT
922        // be auto-retried. Previously this surfaced as RateLimitError{code:0},
923        // which should_retry treats as retryable.
924        let error = ZaiError::ApiError {
925            code: crate::client::error::codes::SDK_TIMEOUT,
926            message: "Timeout waiting for parsing result".to_string(),
927        };
928        assert!(!should_retry(&error, 0, 3));
929    }
930
931    #[test]
932    fn test_http_client_config_default() {
933        let config = HttpClientConfig::default();
934        assert_eq!(config.timeout, Duration::from_secs(60));
935        assert_eq!(config.max_retries, 3);
936        assert!(config.enable_compression);
937        matches!(config.retry_delay, RetryDelay::Exponential { .. });
938    }
939
940    #[test]
941    fn test_retry_delay_default() {
942        let delay = RetryDelay::default();
943        matches!(delay, RetryDelay::Exponential { base, max } if base == Duration::from_millis(500) && max == Duration::from_secs(5));
944    }
945}