1use anyhow::{Context, Result};
4use async_trait::async_trait;
5use futures::StreamExt;
6use std::env;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio_util::sync::CancellationToken;
11
12#[derive(Debug, thiserror::Error)]
16pub enum HttpClientError {
17 #[error("{operation} was cancelled")]
18 Cancelled { operation: String },
19 #[error("{operation} transport failed: {message}")]
20 Transport { operation: String, message: String },
21 #[error("{operation} request was invalid: {message}")]
22 InvalidRequest { operation: String, message: String },
23}
24
25impl HttpClientError {
26 pub fn cancelled(operation: impl Into<String>) -> Self {
27 Self::Cancelled {
28 operation: operation.into(),
29 }
30 }
31
32 pub fn transport(operation: impl Into<String>, message: impl Into<String>) -> Self {
33 Self::Transport {
34 operation: operation.into(),
35 message: message.into(),
36 }
37 }
38
39 fn from_reqwest(operation: &str, error: reqwest::Error) -> Self {
40 if error.is_builder() {
41 Self::InvalidRequest {
42 operation: operation.to_string(),
43 message: error.to_string(),
44 }
45 } else if error.is_timeout() {
46 Self::transport(operation, format!("timed out: {error}"))
51 } else {
52 Self::transport(operation, error.to_string())
53 }
54 }
55
56 pub fn is_retryable(&self) -> bool {
57 match self {
58 Self::Transport { message, .. } => !is_hard_connect_failure(message),
62 Self::Cancelled { .. } | Self::InvalidRequest { .. } => false,
63 }
64 }
65}
66
67pub(crate) fn is_hard_connect_failure(message: &str) -> bool {
70 let lower = message.to_ascii_lowercase();
71 lower.contains("connection reset")
72 || lower.contains("connection refused")
73 || lower.contains("network is unreachable")
74 || lower.contains("no route to host")
75 || lower.contains("name or service not known")
76 || lower.contains("nodename nor servname provided")
77 || lower.contains("failed to lookup address information")
78 || lower.contains("dns error")
79 || lower.contains("could not resolve host")
80 || lower.contains("temporary failure in name resolution")
81}
82
83pub(crate) fn is_retryable_http_failure(error: &anyhow::Error) -> bool {
84 error
85 .downcast_ref::<HttpClientError>()
86 .is_some_and(HttpClientError::is_retryable)
87}
88
89pub struct HttpResponse {
91 pub status: u16,
92 pub body: String,
93}
94
95pub struct StreamingHttpResponse {
97 pub status: u16,
98 pub retry_after: Option<String>,
100 pub byte_stream: Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>,
102 pub error_body: String,
104}
105
106#[derive(Debug, Clone)]
108pub struct HttpMetricsRecord {
109 pub url: String,
111 pub method: String,
113 pub status: u16,
115 pub duration_ms: f64,
117 pub request_bytes: u64,
119 pub response_bytes: u64,
121 pub streaming: bool,
123}
124
125pub type HttpMetricsCallback = Arc<dyn Fn(HttpMetricsRecord) + Send + Sync>;
128
129static HTTP_METRICS_CALLBACK: std::sync::RwLock<Option<HttpMetricsCallback>> =
134 std::sync::RwLock::new(None);
135
136pub fn set_http_metrics_callback(callback: HttpMetricsCallback) {
139 *HTTP_METRICS_CALLBACK.write().unwrap() = Some(callback);
140}
141
142pub fn clear_http_metrics_callback() {
144 *HTTP_METRICS_CALLBACK.write().unwrap() = None;
145}
146
147fn maybe_record_metrics(record: HttpMetricsRecord) {
148 if let Some(callback) = HTTP_METRICS_CALLBACK.read().unwrap().as_ref() {
149 callback(record);
150 }
151}
152
153#[async_trait]
157pub trait HttpClient: Send + Sync {
158 async fn post(
160 &self,
161 url: &str,
162 headers: Vec<(&str, &str)>,
163 body: &serde_json::Value,
164 cancel_token: CancellationToken,
165 ) -> Result<HttpResponse>;
166
167 async fn post_streaming(
170 &self,
171 url: &str,
172 headers: Vec<(&str, &str)>,
173 body: &serde_json::Value,
174 cancel_token: CancellationToken,
175 ) -> Result<StreamingHttpResponse>;
176}
177
178pub struct ReqwestHttpClient {
180 client: reqwest::Client,
181}
182
183impl ReqwestHttpClient {
184 pub fn new() -> Self {
185 Self {
186 client: build_reqwest_client(None, None).expect("failed to build default HTTP client"),
187 }
188 }
189
190 pub fn with_timeout(timeout: Duration) -> Result<Self> {
191 Ok(Self {
192 client: build_reqwest_client(Some(timeout), None)?,
193 })
194 }
195}
196
197impl Default for ReqwestHttpClient {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203#[async_trait]
204impl HttpClient for ReqwestHttpClient {
205 async fn post(
206 &self,
207 url: &str,
208 headers: Vec<(&str, &str)>,
209 body: &serde_json::Value,
210 cancel_token: CancellationToken,
211 ) -> Result<HttpResponse> {
212 let start = std::time::Instant::now();
213 let request_body = serde_json::to_string(body).unwrap_or_default();
214 let request_bytes = request_body.len() as u64;
215
216 tracing::debug!(
217 "HTTP POST to {}: {}",
218 url,
219 serde_json::to_string_pretty(body)?
220 );
221
222 let mut request = self.client.post(url);
223 for (key, value) in headers {
224 request = request.header(key, value);
225 }
226 request = request.json(body);
227
228 let response = tokio::select! {
229 _ = cancel_token.cancelled() => {
230 return Err(anyhow::Error::new(HttpClientError::cancelled("HTTP request")));
231 }
232 result = request.send() => {
233 result.map_err(|error| {
234 anyhow::Error::new(HttpClientError::from_reqwest("HTTP request", error))
235 })?
236 }
237 };
238
239 let status = response.status().as_u16();
240 let response_body = response.text().await.map_err(|error| {
241 anyhow::Error::new(HttpClientError::from_reqwest("HTTP response body", error))
242 })?;
243 let response_bytes = response_body.len() as u64;
244 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
245
246 maybe_record_metrics(HttpMetricsRecord {
247 url: url.to_string(),
248 method: "POST".to_string(),
249 status,
250 duration_ms,
251 request_bytes,
252 response_bytes,
253 streaming: false,
254 });
255
256 Ok(HttpResponse {
257 status,
258 body: response_body,
259 })
260 }
261
262 async fn post_streaming(
263 &self,
264 url: &str,
265 headers: Vec<(&str, &str)>,
266 body: &serde_json::Value,
267 cancel_token: CancellationToken,
268 ) -> Result<StreamingHttpResponse> {
269 let start = std::time::Instant::now();
270 let request_body = serde_json::to_string(body).unwrap_or_default();
271 let request_bytes = request_body.len() as u64;
272
273 let mut request = self.client.post(url);
274 for (key, value) in headers {
275 request = request.header(key, value);
276 }
277 request = request.json(body);
278
279 let response = tokio::select! {
280 _ = cancel_token.cancelled() => {
281 return Err(anyhow::Error::new(HttpClientError::cancelled(
282 "HTTP streaming request",
283 )));
284 }
285 result = request.send() => {
286 result.map_err(|error| {
287 anyhow::Error::new(HttpClientError::from_reqwest(
288 "HTTP streaming request",
289 error,
290 ))
291 })?
292 }
293 };
294
295 let status = response.status().as_u16();
296 let retry_after = response
297 .headers()
298 .get("retry-after")
299 .and_then(|v| v.to_str().ok())
300 .map(String::from);
301
302 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
305 maybe_record_metrics(HttpMetricsRecord {
306 url: url.to_string(),
307 method: "POST".to_string(),
308 status,
309 duration_ms,
310 request_bytes,
311 response_bytes: 0, streaming: true,
313 });
314
315 if (200..300).contains(&status) {
316 let byte_stream = response.bytes_stream().map(|result| {
317 result.map_err(|error| {
318 anyhow::Error::new(HttpClientError::from_reqwest("HTTP response stream", error))
319 })
320 });
321 Ok(StreamingHttpResponse {
322 status,
323 retry_after,
324 byte_stream: Box::pin(byte_stream),
325 error_body: String::new(),
326 })
327 } else {
328 let error_body = response.text().await.unwrap_or_default();
329 let empty: futures::stream::Empty<Result<bytes::Bytes>> = futures::stream::empty();
331 Ok(StreamingHttpResponse {
332 status,
333 retry_after,
334 byte_stream: Box::pin(empty),
335 error_body,
336 })
337 }
338 }
339}
340
341pub fn default_http_client() -> Arc<dyn HttpClient> {
343 Arc::new(ReqwestHttpClient::new())
344}
345
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
347struct ExplicitProxyConfig {
348 http: Option<String>,
349 https: Option<String>,
350}
351
352pub(crate) fn build_reqwest_client(
359 timeout: Option<Duration>,
360 default_headers: Option<reqwest::header::HeaderMap>,
361) -> Result<reqwest::Client> {
362 let mut builder = reqwest::Client::builder().no_proxy();
363
364 if let Some(timeout) = timeout {
365 builder = builder.timeout(timeout);
366 }
367
368 if let Some(default_headers) = default_headers {
369 builder = builder.default_headers(default_headers);
370 }
371
372 let proxy_config = explicit_proxy_config_from_env();
373 if let Some(http_proxy) = proxy_config.http.as_deref() {
374 builder = builder.proxy(
375 reqwest::Proxy::http(http_proxy)
376 .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))?,
377 );
378 }
379 if let Some(https_proxy) = proxy_config.https.as_deref() {
380 builder = builder.proxy(
381 reqwest::Proxy::https(https_proxy)
382 .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))?,
383 );
384 }
385
386 builder.build().context("Failed to build reqwest client")
387}
388
389fn explicit_proxy_config_from_env() -> ExplicitProxyConfig {
390 let http = first_non_empty_env(&["http_proxy", "HTTP_PROXY"]);
391 let https = first_non_empty_env(&["https_proxy", "HTTPS_PROXY"]).or_else(|| http.clone());
392
393 ExplicitProxyConfig { http, https }
394}
395
396fn first_non_empty_env(keys: &[&str]) -> Option<String> {
397 keys.iter().find_map(|key| {
398 env::var(key)
399 .ok()
400 .map(|value| value.trim().to_string())
401 .filter(|value| !value.is_empty())
402 })
403}
404
405pub(crate) fn normalize_base_url(base_url: &str) -> String {
407 base_url
408 .trim_end_matches('/')
409 .trim_end_matches("/v1")
410 .trim_end_matches('/')
411 .to_string()
412}
413
414fn base_has_versioned_api_root(base: &str) -> bool {
415 base.contains("/api/")
416 || base.ends_with("/v4")
417 || base.ends_with("/v3")
418 || base.ends_with("/v2")
419}
420
421pub(crate) fn join_chat_completions_url(base_url: &str, chat_path: &str) -> String {
427 let base = base_url.trim_end_matches('/');
428 if chat_path.is_empty() {
429 return base.to_string();
430 }
431 let mut path = if chat_path.starts_with('/') {
432 chat_path.to_string()
433 } else {
434 format!("/{chat_path}")
435 };
436
437 if base.ends_with("/chat/completions") {
438 return base.to_string();
439 }
440
441 if path.starts_with("/v1/") && base_has_versioned_api_root(base) {
445 path = path.replacen("/v1", "", 1);
446 }
447
448 const PAAS_V4: &str = "/paas/v4";
452 if base.contains(PAAS_V4) {
453 if let Some(idx) = path.find(PAAS_V4) {
454 let suffix = &path[idx + PAAS_V4.len()..];
455 return format!("{base}{suffix}");
456 }
457 }
458
459 format!("{base}{path}")
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::sync::{Mutex, OnceLock};
466 use tokio::io::{AsyncReadExt, AsyncWriteExt};
467
468 #[test]
469 fn retryable_http_failure_requires_a_typed_transport_error() {
470 let prose = anyhow::anyhow!(
471 "Human-readable text says timeout, connection reset, and TLS handshake."
472 );
473 assert!(!is_retryable_http_failure(&prose));
474
475 let transport = anyhow::Error::new(HttpClientError::transport(
476 "stream request",
477 "opaque diagnostic",
478 ));
479 assert!(is_retryable_http_failure(&transport));
480
481 let cancelled = anyhow::Error::new(HttpClientError::cancelled("stream request"));
482 assert!(!is_retryable_http_failure(&cancelled));
483 }
484
485 #[test]
486 fn hard_connect_failures_are_not_retryable() {
487 let reset = anyhow::Error::new(HttpClientError::transport(
488 "API request",
489 "error sending request for url (https://api.deepseek.com/v1/chat/completions): connection reset",
490 ));
491 assert!(!is_retryable_http_failure(&reset));
492 assert!(is_hard_connect_failure(
493 "error sending request … connection reset by peer"
494 ));
495
496 let refused = anyhow::Error::new(HttpClientError::transport(
497 "API request",
498 "tcp connect error: Connection refused (os error 61)",
499 ));
500 assert!(!is_retryable_http_failure(&refused));
501
502 let timeout = anyhow::Error::new(HttpClientError::transport(
503 "API request",
504 "timed out: operation timed out",
505 ));
506 assert!(is_retryable_http_failure(&timeout));
507 }
508
509 fn proxy_env_lock() -> &'static Mutex<()> {
510 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
511 LOCK.get_or_init(|| Mutex::new(()))
512 }
513
514 fn clear_proxy_env() {
515 for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
516 unsafe { env::remove_var(key) };
517 }
518 }
519
520 #[test]
521 fn test_normalize_base_url() {
522 assert_eq!(
523 normalize_base_url("https://api.example.com"),
524 "https://api.example.com"
525 );
526 assert_eq!(
527 normalize_base_url("https://api.example.com/"),
528 "https://api.example.com"
529 );
530 assert_eq!(
531 normalize_base_url("https://api.example.com/v1"),
532 "https://api.example.com"
533 );
534 assert_eq!(
535 normalize_base_url("https://api.example.com/v1/"),
536 "https://api.example.com"
537 );
538 }
539
540 #[test]
541 fn test_normalize_base_url_edge_cases() {
542 assert_eq!(
543 normalize_base_url("http://localhost:8080/v1"),
544 "http://localhost:8080"
545 );
546 assert_eq!(
547 normalize_base_url("http://localhost:8080"),
548 "http://localhost:8080"
549 );
550 assert_eq!(
551 normalize_base_url("https://api.example.com/v1/"),
552 "https://api.example.com"
553 );
554 }
555
556 #[test]
557 fn test_normalize_base_url_multiple_trailing_slashes() {
558 assert_eq!(
559 normalize_base_url("https://api.example.com//"),
560 "https://api.example.com"
561 );
562 }
563
564 #[test]
565 fn test_normalize_base_url_with_port() {
566 assert_eq!(
567 normalize_base_url("http://localhost:11434/v1/"),
568 "http://localhost:11434"
569 );
570 }
571
572 #[test]
573 fn test_normalize_base_url_already_normalized() {
574 assert_eq!(
575 normalize_base_url("https://api.openai.com"),
576 "https://api.openai.com"
577 );
578 }
579
580 #[test]
581 fn test_normalize_base_url_empty_string() {
582 assert_eq!(normalize_base_url(""), "");
583 }
584
585 #[test]
586 fn join_chat_completions_url_openai_default() {
587 assert_eq!(
588 join_chat_completions_url("https://api.openai.com", "/v1/chat/completions"),
589 "https://api.openai.com/v1/chat/completions"
590 );
591 }
592
593 #[test]
594 fn join_chat_completions_url_zhipu_default() {
595 assert_eq!(
596 join_chat_completions_url("https://open.bigmodel.cn", "/api/paas/v4/chat/completions"),
597 "https://open.bigmodel.cn/api/paas/v4/chat/completions"
598 );
599 }
600
601 #[test]
602 fn join_chat_completions_url_coding_plan_openai_compatible() {
603 assert_eq!(
605 join_chat_completions_url(
606 "https://open.bigmodel.cn/api/coding/paas/v4",
607 "/v1/chat/completions"
608 ),
609 "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
610 );
611 }
612
613 #[test]
614 fn join_chat_completions_url_coding_plan_builtin_zhipu() {
615 assert_eq!(
617 join_chat_completions_url(
618 "https://open.bigmodel.cn/api/coding/paas/v4",
619 "/api/paas/v4/chat/completions"
620 ),
621 "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
622 );
623 }
624
625 #[test]
626 fn join_chat_completions_url_full_endpoint_as_base() {
627 assert_eq!(
628 join_chat_completions_url(
629 "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
630 "/v1/chat/completions"
631 ),
632 "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
633 );
634 }
635
636 #[test]
637 fn test_default_http_client_creation() {
638 let _client = default_http_client();
639 }
640
641 #[tokio::test]
642 async fn test_reqwest_http_client_timeout_applies_to_api_call() {
643 let mut last_refused = None;
644 for _ in 0..3 {
645 let (elapsed, err) = post_to_slow_local_server().await;
646 assert!(
647 elapsed < Duration::from_secs(1),
648 "API timeout should fail quickly, elapsed={elapsed:?}"
649 );
650
651 let msg = format!("{err:?}").to_ascii_lowercase();
652 if msg.contains("connection refused") {
653 last_refused = Some(err);
654 continue;
655 }
656
657 assert!(
658 msg.contains("timed out") || msg.contains("timeout"),
659 "expected timeout error, got: {err:?}"
660 );
661 return;
662 }
663
664 panic!(
665 "local timeout server was not reachable after retries; last error: {:?}",
666 last_refused.expect("at least one connection-refused error")
667 );
668 }
669
670 async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
671 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
672 let addr = listener.local_addr().unwrap();
673
674 let server = tokio::spawn(async move {
675 let (mut stream, _) = listener.accept().await.unwrap();
676 let mut buf = [0_u8; 1024];
677 let _ = stream.read(&mut buf).await;
678 tokio::time::sleep(Duration::from_millis(250)).await;
679 let _ = stream
680 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
681 .await;
682 });
683
684 let client = {
689 let _guard = proxy_env_lock().lock().unwrap();
690 clear_proxy_env();
691 ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap()
692 };
693 let started = std::time::Instant::now();
694 let err = match client
695 .post(
696 &format!("http://{addr}/v1/chat/completions"),
697 Vec::new(),
698 &serde_json::json!({"model": "test"}),
699 CancellationToken::new(),
700 )
701 .await
702 {
703 Ok(_) => panic!("expected API timeout error"),
704 Err(err) => err,
705 };
706
707 server.abort();
708 (started.elapsed(), err)
709 }
710
711 #[test]
712 #[cfg(not(windows))]
713 fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
714 let _guard = proxy_env_lock().lock().unwrap();
715 clear_proxy_env();
716 unsafe {
717 env::set_var("http_proxy", "http://lower-http:3128");
718 env::set_var("HTTP_PROXY", "http://upper-http:3128");
719 env::set_var("https_proxy", "http://lower-https:3128");
720 env::set_var("HTTPS_PROXY", "http://upper-https:3128");
721 }
722
723 let proxy_config = explicit_proxy_config_from_env();
724
725 assert_eq!(
726 proxy_config,
727 ExplicitProxyConfig {
728 http: Some("http://lower-http:3128".to_string()),
729 https: Some("http://lower-https:3128".to_string()),
730 }
731 );
732 clear_proxy_env();
733 }
734
735 #[test]
736 fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
737 let _guard = proxy_env_lock().lock().unwrap();
738 clear_proxy_env();
739 unsafe {
740 env::set_var("HTTP_PROXY", "http://proxy.example:3128");
741 }
742
743 let proxy_config = explicit_proxy_config_from_env();
744
745 assert_eq!(
746 proxy_config,
747 ExplicitProxyConfig {
748 http: Some("http://proxy.example:3128".to_string()),
749 https: Some("http://proxy.example:3128".to_string()),
750 }
751 );
752 clear_proxy_env();
753 }
754
755 #[test]
756 fn test_build_reqwest_client_accepts_proxy_env_urls() {
757 let _guard = proxy_env_lock().lock().unwrap();
758 clear_proxy_env();
759 unsafe {
760 env::set_var("http_proxy", "http://127.0.0.1:3128");
761 env::set_var("https_proxy", "http://127.0.0.1:3128");
762 }
763
764 let client = build_reqwest_client(None, None);
765 assert!(client.is_ok());
766 clear_proxy_env();
767 }
768}