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 request/response processing.
5//!
6//! ## Features
7//!
8//! - **Connection Pooling** - Reuses HTTP connections for better performance
9//! - **Error Handling** - Comprehensive error parsing and reporting
10//! - **Authentication** - Bearer token authentication support
11//! - **Logging** - Detailed request/response logging for debugging
12//!
13//! ## Usage
14//!
15//! The `HttpClient` trait provides a standardized interface for making HTTP requests
16//! to the Zhipu AI API endpoints.
17
18use log::{debug, info};
19use serde::Deserialize;
20use std::sync::OnceLock;
21
22#[derive(Debug, Deserialize)]
23struct ApiErrorEnvelope {
24    error: ApiError,
25}
26
27#[derive(Debug, Deserialize)]
28struct ApiError {
29    code: ErrorCode,
30    message: String,
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(untagged)]
35enum ErrorCode {
36    Str(String),
37    Num(i64),
38}
39
40impl std::fmt::Display for ErrorCode {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            ErrorCode::Str(s) => write!(f, "{}", s),
44            ErrorCode::Num(n) => write!(f, "{}", n),
45        }
46    }
47}
48
49/// A single shared HTTP client for connection pooling and TLS reuse.
50///
51/// This static instance ensures that all HTTP requests use the same underlying
52/// connection pool, improving performance by reusing TCP connections and TLS sessions.
53static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
54
55/// Gets the shared HTTP client instance.
56///
57/// Initializes the client on first call with default configuration.
58/// Subsequent calls return the same instance for connection reuse.
59fn http_client() -> &'static reqwest::Client {
60    HTTP_CLIENT.get_or_init(|| {
61        reqwest::Client::builder()
62            .build()
63            .expect("Failed to build reqwest Client")
64    })
65}
66
67/// Trait for HTTP clients that communicate with the Zhipu AI API.
68///
69/// This trait provides a standardized interface for making HTTP requests
70/// to Zhipu AI API endpoints with proper authentication and error handling.
71///
72/// ## Type Parameters
73///
74/// - `Body` - The request body type that implements `Serialize`
75/// - `ApiUrl` - The API URL type that can be referenced as a string
76/// - `ApiKey` - The API key type that can be referenced as a string
77pub trait HttpClient {
78    /// The request body type that must implement JSON serialization.
79    type Body: serde::Serialize;
80
81    /// The API URL type that must be convertible to a string reference.
82    type ApiUrl: AsRef<str>;
83
84    /// The API key type that must be convertible to a string reference.
85    type ApiKey: AsRef<str>;
86
87    /// Returns a reference to the API URL.
88    fn api_url(&self) -> &Self::ApiUrl;
89
90    /// Returns a reference to the API key for authentication.
91    fn api_key(&self) -> &Self::ApiKey;
92
93    /// Returns a reference to the request body.
94    fn body(&self) -> &Self::Body;
95
96    /// Sends a POST request to the API endpoint.
97    ///
98    /// This method handles:
99    /// - JSON serialization of the request body
100    /// - Bearer token authentication
101    /// - Error response parsing and reporting
102    /// - Connection reuse through the shared HTTP client
103    ///
104    /// Returns the HTTP response on success, or an error on failure.
105    fn post(&self) -> impl std::future::Future<Output = anyhow::Result<reqwest::Response>> + Send {
106        let body_compact = serde_json::to_string(self.body());
107        // Only compute pretty JSON when info-level logging is enabled to avoid extra serialization cost
108        let body_pretty_opt = if log::log_enabled!(log::Level::Info) {
109            Some(serde_json::to_string_pretty(self.body()).unwrap_or_default())
110        } else {
111            None
112        };
113        let url = self.api_url().as_ref().to_owned();
114        let key = self.api_key().as_ref().to_owned();
115        async move {
116            let body = body_compact?;
117            if let Some(pretty) = body_pretty_opt {
118                info!("Request body: {}", pretty);
119            }
120            let resp = http_client()
121                .post(url)
122                .bearer_auth(key)
123                .header("Content-Type", "application/json")
124                .body(body)
125                .send()
126                .await?;
127
128            let status = resp.status();
129            if status.is_success() {
130                return Ok(resp);
131            }
132            // Debug headers for troubleshooting on non-2xx
133            debug!(
134                "HTTP {} {} headers: {:?}",
135                status.as_u16(),
136                status.canonical_reason().unwrap_or(""),
137                resp.headers()
138            );
139
140            // Non-success HTTP status: parse error JSON and return Err
141            let text = resp.text().await.unwrap_or_default();
142            if let Ok(parsed) = serde_json::from_str::<ApiErrorEnvelope>(&text) {
143                let code_str = parsed.error.code.to_string();
144                return Err(anyhow::anyhow!(
145                    "HTTP {} {} | code={} | message={}",
146                    status.as_u16(),
147                    status.canonical_reason().unwrap_or(""),
148                    code_str,
149                    parsed.error.message
150                ));
151            } else {
152                return Err(anyhow::anyhow!(
153                    "HTTP {} {} | body={}",
154                    status.as_u16(),
155                    status.canonical_reason().unwrap_or(""),
156                    text
157                ));
158            }
159        }
160    }
161
162    /// Sends a GET request to the API endpoint.
163    ///
164    /// This method is used for endpoints that don't require a request body.
165    /// It handles authentication and error response parsing similar to `post()`.
166    ///
167    /// Returns the HTTP response on success, or an error on failure.
168    fn get(&self) -> impl std::future::Future<Output = anyhow::Result<reqwest::Response>> + Send {
169        let url = self.api_url().as_ref().to_owned();
170        let key = self.api_key().as_ref().to_owned();
171        async move {
172            let resp = http_client().get(url).bearer_auth(key).send().await?;
173
174            let status = resp.status();
175            if status.is_success() {
176                return Ok(resp);
177            }
178            // Debug headers for troubleshooting on non-2xx
179            debug!(
180                "HTTP {} {} headers: {:?}",
181                status.as_u16(),
182                status.canonical_reason().unwrap_or(""),
183                resp.headers()
184            );
185
186            // Non-success HTTP status: parse error JSON and return Err
187            let text = resp.text().await.unwrap_or_default();
188            if let Ok(parsed) = serde_json::from_str::<ApiErrorEnvelope>(&text) {
189                let code_str = parsed.error.code.to_string();
190                return Err(anyhow::anyhow!(
191                    "HTTP {} {} | code={} | message={}",
192                    status.as_u16(),
193                    status.canonical_reason().unwrap_or(""),
194                    code_str,
195                    parsed.error.message
196                ));
197            } else {
198                return Err(anyhow::anyhow!(
199                    "HTTP {} {} | body={}",
200                    status.as_u16(),
201                    status.canonical_reason().unwrap_or(""),
202                    text
203                ));
204            }
205        }
206    }
207}