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 matches!(self, Self::Transport { .. })
58 }
59}
60
61pub(crate) fn is_retryable_http_failure(error: &anyhow::Error) -> bool {
62 error
63 .downcast_ref::<HttpClientError>()
64 .is_some_and(HttpClientError::is_retryable)
65}
66
67pub struct HttpResponse {
69 pub status: u16,
70 pub body: String,
71}
72
73pub struct StreamingHttpResponse {
75 pub status: u16,
76 pub retry_after: Option<String>,
78 pub byte_stream: Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>,
80 pub error_body: String,
82}
83
84#[derive(Debug, Clone)]
86pub struct HttpMetricsRecord {
87 pub url: String,
89 pub method: String,
91 pub status: u16,
93 pub duration_ms: f64,
95 pub request_bytes: u64,
97 pub response_bytes: u64,
99 pub streaming: bool,
101}
102
103pub type HttpMetricsCallback = Arc<dyn Fn(HttpMetricsRecord) + Send + Sync>;
106
107static HTTP_METRICS_CALLBACK: std::sync::RwLock<Option<HttpMetricsCallback>> =
112 std::sync::RwLock::new(None);
113
114pub fn set_http_metrics_callback(callback: HttpMetricsCallback) {
117 *HTTP_METRICS_CALLBACK.write().unwrap() = Some(callback);
118}
119
120pub fn clear_http_metrics_callback() {
122 *HTTP_METRICS_CALLBACK.write().unwrap() = None;
123}
124
125fn maybe_record_metrics(record: HttpMetricsRecord) {
126 if let Some(callback) = HTTP_METRICS_CALLBACK.read().unwrap().as_ref() {
127 callback(record);
128 }
129}
130
131#[async_trait]
135pub trait HttpClient: Send + Sync {
136 async fn post(
138 &self,
139 url: &str,
140 headers: Vec<(&str, &str)>,
141 body: &serde_json::Value,
142 cancel_token: CancellationToken,
143 ) -> Result<HttpResponse>;
144
145 async fn post_streaming(
148 &self,
149 url: &str,
150 headers: Vec<(&str, &str)>,
151 body: &serde_json::Value,
152 cancel_token: CancellationToken,
153 ) -> Result<StreamingHttpResponse>;
154}
155
156pub struct ReqwestHttpClient {
158 client: reqwest::Client,
159}
160
161impl ReqwestHttpClient {
162 pub fn new() -> Self {
163 Self {
164 client: build_reqwest_client(None, None).expect("failed to build default HTTP client"),
165 }
166 }
167
168 pub fn with_timeout(timeout: Duration) -> Result<Self> {
169 Ok(Self {
170 client: build_reqwest_client(Some(timeout), None)?,
171 })
172 }
173}
174
175impl Default for ReqwestHttpClient {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181#[async_trait]
182impl HttpClient for ReqwestHttpClient {
183 async fn post(
184 &self,
185 url: &str,
186 headers: Vec<(&str, &str)>,
187 body: &serde_json::Value,
188 cancel_token: CancellationToken,
189 ) -> Result<HttpResponse> {
190 let start = std::time::Instant::now();
191 let request_body = serde_json::to_string(body).unwrap_or_default();
192 let request_bytes = request_body.len() as u64;
193
194 tracing::debug!(
195 "HTTP POST to {}: {}",
196 url,
197 serde_json::to_string_pretty(body)?
198 );
199
200 let mut request = self.client.post(url);
201 for (key, value) in headers {
202 request = request.header(key, value);
203 }
204 request = request.json(body);
205
206 let response = tokio::select! {
207 _ = cancel_token.cancelled() => {
208 return Err(anyhow::Error::new(HttpClientError::cancelled("HTTP request")));
209 }
210 result = request.send() => {
211 result.map_err(|error| {
212 anyhow::Error::new(HttpClientError::from_reqwest("HTTP request", error))
213 })?
214 }
215 };
216
217 let status = response.status().as_u16();
218 let response_body = response.text().await.map_err(|error| {
219 anyhow::Error::new(HttpClientError::from_reqwest("HTTP response body", error))
220 })?;
221 let response_bytes = response_body.len() as u64;
222 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
223
224 maybe_record_metrics(HttpMetricsRecord {
225 url: url.to_string(),
226 method: "POST".to_string(),
227 status,
228 duration_ms,
229 request_bytes,
230 response_bytes,
231 streaming: false,
232 });
233
234 Ok(HttpResponse {
235 status,
236 body: response_body,
237 })
238 }
239
240 async fn post_streaming(
241 &self,
242 url: &str,
243 headers: Vec<(&str, &str)>,
244 body: &serde_json::Value,
245 cancel_token: CancellationToken,
246 ) -> Result<StreamingHttpResponse> {
247 let start = std::time::Instant::now();
248 let request_body = serde_json::to_string(body).unwrap_or_default();
249 let request_bytes = request_body.len() as u64;
250
251 let mut request = self.client.post(url);
252 for (key, value) in headers {
253 request = request.header(key, value);
254 }
255 request = request.json(body);
256
257 let response = tokio::select! {
258 _ = cancel_token.cancelled() => {
259 return Err(anyhow::Error::new(HttpClientError::cancelled(
260 "HTTP streaming request",
261 )));
262 }
263 result = request.send() => {
264 result.map_err(|error| {
265 anyhow::Error::new(HttpClientError::from_reqwest(
266 "HTTP streaming request",
267 error,
268 ))
269 })?
270 }
271 };
272
273 let status = response.status().as_u16();
274 let retry_after = response
275 .headers()
276 .get("retry-after")
277 .and_then(|v| v.to_str().ok())
278 .map(String::from);
279
280 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
283 maybe_record_metrics(HttpMetricsRecord {
284 url: url.to_string(),
285 method: "POST".to_string(),
286 status,
287 duration_ms,
288 request_bytes,
289 response_bytes: 0, streaming: true,
291 });
292
293 if (200..300).contains(&status) {
294 let byte_stream = response.bytes_stream().map(|result| {
295 result.map_err(|error| {
296 anyhow::Error::new(HttpClientError::from_reqwest("HTTP response stream", error))
297 })
298 });
299 Ok(StreamingHttpResponse {
300 status,
301 retry_after,
302 byte_stream: Box::pin(byte_stream),
303 error_body: String::new(),
304 })
305 } else {
306 let error_body = response.text().await.unwrap_or_default();
307 let empty: futures::stream::Empty<Result<bytes::Bytes>> = futures::stream::empty();
309 Ok(StreamingHttpResponse {
310 status,
311 retry_after,
312 byte_stream: Box::pin(empty),
313 error_body,
314 })
315 }
316 }
317}
318
319pub fn default_http_client() -> Arc<dyn HttpClient> {
321 Arc::new(ReqwestHttpClient::new())
322}
323
324#[derive(Debug, Clone, Default, PartialEq, Eq)]
325struct ExplicitProxyConfig {
326 http: Option<String>,
327 https: Option<String>,
328}
329
330pub(crate) fn build_reqwest_client(
337 timeout: Option<Duration>,
338 default_headers: Option<reqwest::header::HeaderMap>,
339) -> Result<reqwest::Client> {
340 let mut builder = reqwest::Client::builder().no_proxy();
341
342 if let Some(timeout) = timeout {
343 builder = builder.timeout(timeout);
344 }
345
346 if let Some(default_headers) = default_headers {
347 builder = builder.default_headers(default_headers);
348 }
349
350 let proxy_config = explicit_proxy_config_from_env();
351 if let Some(http_proxy) = proxy_config.http.as_deref() {
352 builder = builder.proxy(
353 reqwest::Proxy::http(http_proxy)
354 .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))?,
355 );
356 }
357 if let Some(https_proxy) = proxy_config.https.as_deref() {
358 builder = builder.proxy(
359 reqwest::Proxy::https(https_proxy)
360 .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))?,
361 );
362 }
363
364 builder.build().context("Failed to build reqwest client")
365}
366
367fn explicit_proxy_config_from_env() -> ExplicitProxyConfig {
368 let http = first_non_empty_env(&["http_proxy", "HTTP_PROXY"]);
369 let https = first_non_empty_env(&["https_proxy", "HTTPS_PROXY"]).or_else(|| http.clone());
370
371 ExplicitProxyConfig { http, https }
372}
373
374fn first_non_empty_env(keys: &[&str]) -> Option<String> {
375 keys.iter().find_map(|key| {
376 env::var(key)
377 .ok()
378 .map(|value| value.trim().to_string())
379 .filter(|value| !value.is_empty())
380 })
381}
382
383pub(crate) fn normalize_base_url(base_url: &str) -> String {
385 base_url
386 .trim_end_matches('/')
387 .trim_end_matches("/v1")
388 .trim_end_matches('/')
389 .to_string()
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use std::sync::{Mutex, OnceLock};
396 use tokio::io::{AsyncReadExt, AsyncWriteExt};
397
398 #[test]
399 fn retryable_http_failure_requires_a_typed_transport_error() {
400 let prose = anyhow::anyhow!(
401 "Human-readable text says timeout, connection reset, and TLS handshake."
402 );
403 assert!(!is_retryable_http_failure(&prose));
404
405 let transport = anyhow::Error::new(HttpClientError::transport(
406 "stream request",
407 "opaque diagnostic",
408 ));
409 assert!(is_retryable_http_failure(&transport));
410
411 let cancelled = anyhow::Error::new(HttpClientError::cancelled("stream request"));
412 assert!(!is_retryable_http_failure(&cancelled));
413 }
414
415 fn proxy_env_lock() -> &'static Mutex<()> {
416 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
417 LOCK.get_or_init(|| Mutex::new(()))
418 }
419
420 fn clear_proxy_env() {
421 for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
422 unsafe { env::remove_var(key) };
423 }
424 }
425
426 #[test]
427 fn test_normalize_base_url() {
428 assert_eq!(
429 normalize_base_url("https://api.example.com"),
430 "https://api.example.com"
431 );
432 assert_eq!(
433 normalize_base_url("https://api.example.com/"),
434 "https://api.example.com"
435 );
436 assert_eq!(
437 normalize_base_url("https://api.example.com/v1"),
438 "https://api.example.com"
439 );
440 assert_eq!(
441 normalize_base_url("https://api.example.com/v1/"),
442 "https://api.example.com"
443 );
444 }
445
446 #[test]
447 fn test_normalize_base_url_edge_cases() {
448 assert_eq!(
449 normalize_base_url("http://localhost:8080/v1"),
450 "http://localhost:8080"
451 );
452 assert_eq!(
453 normalize_base_url("http://localhost:8080"),
454 "http://localhost:8080"
455 );
456 assert_eq!(
457 normalize_base_url("https://api.example.com/v1/"),
458 "https://api.example.com"
459 );
460 }
461
462 #[test]
463 fn test_normalize_base_url_multiple_trailing_slashes() {
464 assert_eq!(
465 normalize_base_url("https://api.example.com//"),
466 "https://api.example.com"
467 );
468 }
469
470 #[test]
471 fn test_normalize_base_url_with_port() {
472 assert_eq!(
473 normalize_base_url("http://localhost:11434/v1/"),
474 "http://localhost:11434"
475 );
476 }
477
478 #[test]
479 fn test_normalize_base_url_already_normalized() {
480 assert_eq!(
481 normalize_base_url("https://api.openai.com"),
482 "https://api.openai.com"
483 );
484 }
485
486 #[test]
487 fn test_normalize_base_url_empty_string() {
488 assert_eq!(normalize_base_url(""), "");
489 }
490
491 #[test]
492 fn test_default_http_client_creation() {
493 let _client = default_http_client();
494 }
495
496 #[tokio::test]
497 async fn test_reqwest_http_client_timeout_applies_to_api_call() {
498 let mut last_refused = None;
499 for _ in 0..3 {
500 let (elapsed, err) = post_to_slow_local_server().await;
501 assert!(
502 elapsed < Duration::from_secs(1),
503 "API timeout should fail quickly, elapsed={elapsed:?}"
504 );
505
506 let msg = format!("{err:?}").to_ascii_lowercase();
507 if msg.contains("connection refused") {
508 last_refused = Some(err);
509 continue;
510 }
511
512 assert!(
513 msg.contains("timed out") || msg.contains("timeout"),
514 "expected timeout error, got: {err:?}"
515 );
516 return;
517 }
518
519 panic!(
520 "local timeout server was not reachable after retries; last error: {:?}",
521 last_refused.expect("at least one connection-refused error")
522 );
523 }
524
525 async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
526 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
527 let addr = listener.local_addr().unwrap();
528
529 let server = tokio::spawn(async move {
530 let (mut stream, _) = listener.accept().await.unwrap();
531 let mut buf = [0_u8; 1024];
532 let _ = stream.read(&mut buf).await;
533 tokio::time::sleep(Duration::from_millis(250)).await;
534 let _ = stream
535 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
536 .await;
537 });
538
539 let client = ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap();
540 let started = std::time::Instant::now();
541 let err = match client
542 .post(
543 &format!("http://{addr}/v1/chat/completions"),
544 Vec::new(),
545 &serde_json::json!({"model": "test"}),
546 CancellationToken::new(),
547 )
548 .await
549 {
550 Ok(_) => panic!("expected API timeout error"),
551 Err(err) => err,
552 };
553
554 server.abort();
555 (started.elapsed(), err)
556 }
557
558 #[test]
559 #[cfg(not(windows))]
560 fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
561 let _guard = proxy_env_lock().lock().unwrap();
562 clear_proxy_env();
563 unsafe {
564 env::set_var("http_proxy", "http://lower-http:3128");
565 env::set_var("HTTP_PROXY", "http://upper-http:3128");
566 env::set_var("https_proxy", "http://lower-https:3128");
567 env::set_var("HTTPS_PROXY", "http://upper-https:3128");
568 }
569
570 let proxy_config = explicit_proxy_config_from_env();
571
572 assert_eq!(
573 proxy_config,
574 ExplicitProxyConfig {
575 http: Some("http://lower-http:3128".to_string()),
576 https: Some("http://lower-https:3128".to_string()),
577 }
578 );
579 clear_proxy_env();
580 }
581
582 #[test]
583 fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
584 let _guard = proxy_env_lock().lock().unwrap();
585 clear_proxy_env();
586 unsafe {
587 env::set_var("HTTP_PROXY", "http://proxy.example:3128");
588 }
589
590 let proxy_config = explicit_proxy_config_from_env();
591
592 assert_eq!(
593 proxy_config,
594 ExplicitProxyConfig {
595 http: Some("http://proxy.example:3128".to_string()),
596 https: Some("http://proxy.example:3128".to_string()),
597 }
598 );
599 clear_proxy_env();
600 }
601
602 #[test]
603 fn test_build_reqwest_client_accepts_proxy_env_urls() {
604 let _guard = proxy_env_lock().lock().unwrap();
605 clear_proxy_env();
606 unsafe {
607 env::set_var("http_proxy", "http://127.0.0.1:3128");
608 env::set_var("https_proxy", "http://127.0.0.1:3128");
609 }
610
611 let client = build_reqwest_client(None, None);
612 assert!(client.is_ok());
613 clear_proxy_env();
614 }
615}