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
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::sync::{Mutex, OnceLock};
418 use tokio::io::{AsyncReadExt, AsyncWriteExt};
419
420 #[test]
421 fn retryable_http_failure_requires_a_typed_transport_error() {
422 let prose = anyhow::anyhow!(
423 "Human-readable text says timeout, connection reset, and TLS handshake."
424 );
425 assert!(!is_retryable_http_failure(&prose));
426
427 let transport = anyhow::Error::new(HttpClientError::transport(
428 "stream request",
429 "opaque diagnostic",
430 ));
431 assert!(is_retryable_http_failure(&transport));
432
433 let cancelled = anyhow::Error::new(HttpClientError::cancelled("stream request"));
434 assert!(!is_retryable_http_failure(&cancelled));
435 }
436
437 #[test]
438 fn hard_connect_failures_are_not_retryable() {
439 let reset = anyhow::Error::new(HttpClientError::transport(
440 "API request",
441 "error sending request for url (https://api.deepseek.com/v1/chat/completions): connection reset",
442 ));
443 assert!(!is_retryable_http_failure(&reset));
444 assert!(is_hard_connect_failure(
445 "error sending request … connection reset by peer"
446 ));
447
448 let refused = anyhow::Error::new(HttpClientError::transport(
449 "API request",
450 "tcp connect error: Connection refused (os error 61)",
451 ));
452 assert!(!is_retryable_http_failure(&refused));
453
454 let timeout = anyhow::Error::new(HttpClientError::transport(
455 "API request",
456 "timed out: operation timed out",
457 ));
458 assert!(is_retryable_http_failure(&timeout));
459 }
460
461 fn proxy_env_lock() -> &'static Mutex<()> {
462 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
463 LOCK.get_or_init(|| Mutex::new(()))
464 }
465
466 fn clear_proxy_env() {
467 for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
468 unsafe { env::remove_var(key) };
469 }
470 }
471
472 #[test]
473 fn test_normalize_base_url() {
474 assert_eq!(
475 normalize_base_url("https://api.example.com"),
476 "https://api.example.com"
477 );
478 assert_eq!(
479 normalize_base_url("https://api.example.com/"),
480 "https://api.example.com"
481 );
482 assert_eq!(
483 normalize_base_url("https://api.example.com/v1"),
484 "https://api.example.com"
485 );
486 assert_eq!(
487 normalize_base_url("https://api.example.com/v1/"),
488 "https://api.example.com"
489 );
490 }
491
492 #[test]
493 fn test_normalize_base_url_edge_cases() {
494 assert_eq!(
495 normalize_base_url("http://localhost:8080/v1"),
496 "http://localhost:8080"
497 );
498 assert_eq!(
499 normalize_base_url("http://localhost:8080"),
500 "http://localhost:8080"
501 );
502 assert_eq!(
503 normalize_base_url("https://api.example.com/v1/"),
504 "https://api.example.com"
505 );
506 }
507
508 #[test]
509 fn test_normalize_base_url_multiple_trailing_slashes() {
510 assert_eq!(
511 normalize_base_url("https://api.example.com//"),
512 "https://api.example.com"
513 );
514 }
515
516 #[test]
517 fn test_normalize_base_url_with_port() {
518 assert_eq!(
519 normalize_base_url("http://localhost:11434/v1/"),
520 "http://localhost:11434"
521 );
522 }
523
524 #[test]
525 fn test_normalize_base_url_already_normalized() {
526 assert_eq!(
527 normalize_base_url("https://api.openai.com"),
528 "https://api.openai.com"
529 );
530 }
531
532 #[test]
533 fn test_normalize_base_url_empty_string() {
534 assert_eq!(normalize_base_url(""), "");
535 }
536
537 #[test]
538 fn test_default_http_client_creation() {
539 let _client = default_http_client();
540 }
541
542 #[tokio::test]
543 async fn test_reqwest_http_client_timeout_applies_to_api_call() {
544 let mut last_refused = None;
545 for _ in 0..3 {
546 let (elapsed, err) = post_to_slow_local_server().await;
547 assert!(
548 elapsed < Duration::from_secs(1),
549 "API timeout should fail quickly, elapsed={elapsed:?}"
550 );
551
552 let msg = format!("{err:?}").to_ascii_lowercase();
553 if msg.contains("connection refused") {
554 last_refused = Some(err);
555 continue;
556 }
557
558 assert!(
559 msg.contains("timed out") || msg.contains("timeout"),
560 "expected timeout error, got: {err:?}"
561 );
562 return;
563 }
564
565 panic!(
566 "local timeout server was not reachable after retries; last error: {:?}",
567 last_refused.expect("at least one connection-refused error")
568 );
569 }
570
571 async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
572 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
573 let addr = listener.local_addr().unwrap();
574
575 let server = tokio::spawn(async move {
576 let (mut stream, _) = listener.accept().await.unwrap();
577 let mut buf = [0_u8; 1024];
578 let _ = stream.read(&mut buf).await;
579 tokio::time::sleep(Duration::from_millis(250)).await;
580 let _ = stream
581 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
582 .await;
583 });
584
585 let client = {
590 let _guard = proxy_env_lock().lock().unwrap();
591 clear_proxy_env();
592 ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap()
593 };
594 let started = std::time::Instant::now();
595 let err = match client
596 .post(
597 &format!("http://{addr}/v1/chat/completions"),
598 Vec::new(),
599 &serde_json::json!({"model": "test"}),
600 CancellationToken::new(),
601 )
602 .await
603 {
604 Ok(_) => panic!("expected API timeout error"),
605 Err(err) => err,
606 };
607
608 server.abort();
609 (started.elapsed(), err)
610 }
611
612 #[test]
613 #[cfg(not(windows))]
614 fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
615 let _guard = proxy_env_lock().lock().unwrap();
616 clear_proxy_env();
617 unsafe {
618 env::set_var("http_proxy", "http://lower-http:3128");
619 env::set_var("HTTP_PROXY", "http://upper-http:3128");
620 env::set_var("https_proxy", "http://lower-https:3128");
621 env::set_var("HTTPS_PROXY", "http://upper-https:3128");
622 }
623
624 let proxy_config = explicit_proxy_config_from_env();
625
626 assert_eq!(
627 proxy_config,
628 ExplicitProxyConfig {
629 http: Some("http://lower-http:3128".to_string()),
630 https: Some("http://lower-https:3128".to_string()),
631 }
632 );
633 clear_proxy_env();
634 }
635
636 #[test]
637 fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
638 let _guard = proxy_env_lock().lock().unwrap();
639 clear_proxy_env();
640 unsafe {
641 env::set_var("HTTP_PROXY", "http://proxy.example:3128");
642 }
643
644 let proxy_config = explicit_proxy_config_from_env();
645
646 assert_eq!(
647 proxy_config,
648 ExplicitProxyConfig {
649 http: Some("http://proxy.example:3128".to_string()),
650 https: Some("http://proxy.example:3128".to_string()),
651 }
652 );
653 clear_proxy_env();
654 }
655
656 #[test]
657 fn test_build_reqwest_client_accepts_proxy_env_urls() {
658 let _guard = proxy_env_lock().lock().unwrap();
659 clear_proxy_env();
660 unsafe {
661 env::set_var("http_proxy", "http://127.0.0.1:3128");
662 env::set_var("https_proxy", "http://127.0.0.1:3128");
663 }
664
665 let client = build_reqwest_client(None, None);
666 assert!(client.is_ok());
667 clear_proxy_env();
668 }
669}