use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime};
use bytes::Bytes;
use futures::stream::{BoxStream, StreamExt};
use reqwest::Client;
use aimux_core::AiMuxError;
use aimux_core::shared::AbortSignal;
use crate::response::{ErrorStructure, parse_provider_error};
use crate::retry::{RetryConfig, get_retry_delay_ms_with_jitter, parse_retry_after};
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub max_idle_per_host: usize,
pub idle_timeout_secs: u64,
pub keep_alive: bool,
pub keep_alive_interval_secs: u64,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_idle_per_host: 10,
idle_timeout_secs: 30,
keep_alive: true,
keep_alive_interval_secs: 20,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TimeoutConfig {
pub connect_timeout_ms: u64,
pub response_timeout_ms: u64,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout_ms: 10_000,
response_timeout_ms: 30_000,
}
}
}
impl TimeoutConfig {
pub fn streaming() -> Self {
Self {
connect_timeout_ms: 10_000,
response_timeout_ms: 0,
}
}
}
static SHARED: OnceLock<Client> = OnceLock::new();
static SHARED_STREAMING: OnceLock<Client> = OnceLock::new();
pub fn shared_client() -> &'static Client {
SHARED.get_or_init(|| build_client(PoolConfig::default(), TimeoutConfig::default()))
}
pub fn shared_streaming_client() -> &'static Client {
SHARED_STREAMING.get_or_init(|| build_client(PoolConfig::default(), TimeoutConfig::streaming()))
}
fn build_client(pool: PoolConfig, timeout: TimeoutConfig) -> Client {
let mut b = Client::builder()
.connect_timeout(Duration::from_millis(timeout.connect_timeout_ms))
.pool_max_idle_per_host(pool.max_idle_per_host)
.pool_idle_timeout(Some(Duration::from_secs(pool.idle_timeout_secs)));
if pool.keep_alive {
b = b.tcp_keepalive(Some(Duration::from_secs(pool.keep_alive_interval_secs)));
}
if timeout.response_timeout_ms > 0 {
b = b.timeout(Duration::from_millis(timeout.response_timeout_ms));
}
b.build().expect("shared reqwest Client build failed")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
#[derive(Debug, Clone)]
pub enum HttpBody {
Json(serde_json::Value),
Bytes(Vec<u8>, String),
Empty,
}
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: HttpMethod,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: HttpBody,
pub abort_signal: Option<AbortSignal>,
}
#[derive(Debug)]
pub struct HttpResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Bytes,
}
pub struct HttpStreamResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: BoxStream<'static, Result<Bytes, AiMuxError>>,
}
pub async fn send(
request: HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
) -> Result<HttpResponse, AiMuxError> {
let client = shared_client();
let resp = send_with_retry_raw(client, &request, retry_config, error_structure).await?;
let status = resp.status().as_u16();
let headers = collect_headers(resp.headers());
let body = resp
.bytes()
.await
.map_err(|e| AiMuxError::Http(e.to_string()))?;
Ok(HttpResponse {
status,
headers,
body,
})
}
pub async fn send_stream(
request: HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
) -> Result<HttpStreamResponse, AiMuxError> {
let client = shared_streaming_client();
let resp = send_with_retry_raw(client, &request, retry_config, error_structure).await?;
let status = resp.status().as_u16();
let headers = collect_headers(resp.headers());
let body = resp
.bytes_stream()
.map(|item| item.map_err(|e| AiMuxError::Http(e.to_string())))
.boxed();
Ok(HttpStreamResponse {
status,
headers,
body,
})
}
async fn send_with_retry_raw(
client: &Client,
request: &HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
) -> Result<reqwest::Response, AiMuxError> {
let mut last_error = AiMuxError::Other("no attempts made".to_string());
let mut exponential_delay_ms = retry_config.initial_delay.as_millis() as i64;
for attempt in 0..=retry_config.max_retries {
let resp = send_request(client, request).await;
match resp {
Ok(resp) if resp.status().is_success() => return Ok(resp),
Ok(resp) => {
let status_code = resp.status().as_u16();
if status_code == 429 {
let hint = parse_retry_after(
resp.headers()
.get("retry-after-ms")
.and_then(|v| v.to_str().ok()),
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
SystemTime::now(),
);
let _ = resp.text().await; last_error = AiMuxError::RateLimited {
retry_after_ms: hint.unwrap_or(1000).max(0) as u64,
};
} else if resp.status().is_server_error() {
let body = resp.text().await.unwrap_or_default();
last_error = AiMuxError::ApiCall(format!("HTTP {}: {}", status_code, body));
} else {
let body = resp.text().await.unwrap_or_default();
return Err(parse_provider_error(status_code, &body, error_structure));
}
}
Err(e) => {
last_error = e;
}
}
if !last_error.is_retryable() || attempt == retry_config.max_retries {
return Err(last_error);
}
let hint = last_error.retry_after_hint();
let delay_ms = {
let mut rng = rand::thread_rng();
get_retry_delay_ms_with_jitter(hint, exponential_delay_ms, &mut rng)
};
tokio::time::sleep(Duration::from_millis(delay_ms.max(0) as u64)).await;
exponential_delay_ms =
exponential_delay_ms.saturating_mul(retry_config.backoff_factor as i64);
}
Err(last_error)
}
async fn abort_wait(signal: &AbortSignal) {
while !signal.is_aborted() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn send_request(
client: &Client,
request: &HttpRequest,
) -> Result<reqwest::Response, AiMuxError> {
if let Some(signal) = &request.abort_signal {
if signal.is_aborted() {
return Err(AiMuxError::Other(
"request aborted before sending".to_string(),
));
}
let response = build_request_builder(client, request)?.send();
tokio::select! {
result = response => result.map_err(|e| AiMuxError::Http(e.to_string())),
_ = abort_wait(signal) => Err(AiMuxError::Other("request aborted".to_string())),
}
} else {
build_request_builder(client, request)?
.send()
.await
.map_err(|e| AiMuxError::Http(e.to_string()))
}
}
fn build_request_builder(
client: &Client,
request: &HttpRequest,
) -> Result<reqwest::RequestBuilder, AiMuxError> {
let mut builder = match request.method {
HttpMethod::Get => client.get(&request.url),
HttpMethod::Post => client.post(&request.url),
HttpMethod::Put => client.put(&request.url),
HttpMethod::Delete => client.delete(&request.url),
HttpMethod::Patch => client.patch(&request.url),
};
for (name, value) in &request.headers {
let header_name = reqwest::header::HeaderName::try_from(name)
.map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?;
let header_value = reqwest::header::HeaderValue::try_from(value).map_err(|_| {
AiMuxError::InvalidArgument(format!("invalid header value for {name}: {value}"))
})?;
builder = builder.header(header_name, header_value);
}
let builder = match &request.body {
HttpBody::Json(value) => builder.json(value),
HttpBody::Bytes(bytes, content_type) => builder
.header("Content-Type", content_type)
.body(bytes.clone()),
HttpBody::Empty => builder,
};
Ok(builder)
}
fn collect_headers(headers: &reqwest::header::HeaderMap) -> HashMap<String, String> {
headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_client_is_stable_handle() {
let a = shared_client();
let b = shared_client();
assert!(std::ptr::eq(a, b));
}
#[test]
fn shared_streaming_client_is_stable_handle() {
let a = shared_streaming_client();
let b = shared_streaming_client();
assert!(std::ptr::eq(a, b));
}
#[test]
fn shared_and_streaming_are_distinct() {
assert!(!std::ptr::eq(shared_client(), shared_streaming_client()));
}
#[test]
fn streaming_config_has_no_response_timeout() {
let t = TimeoutConfig::streaming();
assert_eq!(t.response_timeout_ms, 0);
assert_ne!(t.connect_timeout_ms, 0);
}
#[test]
fn default_config_has_response_timeout() {
let t = TimeoutConfig::default();
assert_ne!(t.response_timeout_ms, 0);
}
#[test]
fn http_request_is_clone_for_retry() {
let req = HttpRequest {
method: HttpMethod::Post,
url: "https://example.com".to_string(),
headers: vec![("Authorization".to_string(), "Bearer x".to_string())],
body: HttpBody::Json(serde_json::json!({"q": "hi"})),
abort_signal: None,
};
let _clone = req.clone();
assert_eq!(req.method, HttpMethod::Post);
}
#[test]
fn build_request_builder_rejects_invalid_header_name() {
let client = Client::new();
let req = HttpRequest {
method: HttpMethod::Get,
url: "https://example.com".to_string(),
headers: vec![("Invalid Header\r\n".to_string(), "secret".to_string())],
body: HttpBody::Empty,
abort_signal: None,
};
let err = build_request_builder(&client, &req).unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)), "got {err:?}");
assert!(err.to_string().contains("invalid header name"));
}
#[test]
fn build_request_builder_rejects_invalid_header_value() {
let client = Client::new();
let req = HttpRequest {
method: HttpMethod::Get,
url: "https://example.com".to_string(),
headers: vec![(
"Authorization".to_string(),
"Bearer evil\r\nX-Injected: yes".to_string(),
)],
body: HttpBody::Empty,
abort_signal: None,
};
let err = build_request_builder(&client, &req).unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)), "got {err:?}");
assert!(err.to_string().contains("invalid header value"));
}
#[test]
fn build_request_builder_accepts_valid_headers() {
let client = Client::new();
let req = HttpRequest {
method: HttpMethod::Post,
url: "https://example.com".to_string(),
headers: vec![
("Authorization".to_string(), "Bearer token".to_string()),
("X-Custom".to_string(), "value".to_string()),
],
body: HttpBody::Json(serde_json::json!({"q": "hi"})),
abort_signal: None,
};
assert!(build_request_builder(&client, &req).is_ok());
}
}