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