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}