use std::collections::HashMap;
use std::pin::Pin;
use std::sync::OnceLock;
use std::task::{Context, Poll};
use std::time::{Duration, Instant, SystemTime};
use bytes::Bytes;
use futures::stream::{BoxStream, Stream, StreamExt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use aimux_core::AiMuxError;
use aimux_core::ApiCallError;
use aimux_core::options::TimeoutConfiguration;
use aimux_core::shared::AbortSignal;
use crate::logging::{auto_init_from_env, body_logging_enabled, redact_body};
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 {
#[must_use]
pub fn streaming() -> Self {
Self {
connect_timeout_ms: 10_000,
response_timeout_ms: 0,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyConfig {
pub http_url: Option<String>,
pub https_url: Option<String>,
pub all_url: Option<String>,
pub no_proxy: Option<String>,
}
static GLOBAL_PROXY: OnceLock<ProxyConfig> = OnceLock::new();
pub fn init_proxy(config: ProxyConfig) -> bool {
GLOBAL_PROXY.set(config).is_ok()
}
fn global_proxy() -> ProxyConfig {
GLOBAL_PROXY.get().cloned().unwrap_or_default()
}
static SHARED: OnceLock<Result<Client, String>> = OnceLock::new();
static SHARED_STREAMING: OnceLock<Result<Client, String>> = OnceLock::new();
fn client_init_error(source: &str) -> AiMuxError {
AiMuxError::ApiCall(ApiCallError {
message: format!("shared HTTP client initialization failed: {source}"),
..Default::default()
})
}
pub fn shared_client() -> Result<&'static Client, AiMuxError> {
SHARED
.get_or_init(|| {
build_client(
PoolConfig::default(),
TimeoutConfig::default(),
global_proxy(),
)
})
.as_ref()
.map_err(|e| client_init_error(e))
}
pub fn shared_streaming_client() -> Result<&'static Client, AiMuxError> {
SHARED_STREAMING
.get_or_init(|| {
build_client(
PoolConfig::default(),
TimeoutConfig::streaming(),
global_proxy(),
)
})
.as_ref()
.map_err(|e| client_init_error(e))
}
fn build_client(
pool: PoolConfig,
timeout: TimeoutConfig,
proxy: ProxyConfig,
) -> Result<Client, String> {
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 = apply_proxy(b, &proxy);
b.build().map_err(|e| e.to_string())
}
fn apply_proxy(b: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder {
use reqwest::Proxy as ReqwestProxy;
let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref());
let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref());
let mut b = b;
if let Some(url) = http
&& let Ok(p) = ReqwestProxy::http(url)
{
b = apply_no_proxy(b, p, &proxy.no_proxy);
}
if let Some(url) = https
&& let Ok(p) = ReqwestProxy::https(url)
{
b = apply_no_proxy(b, p, &proxy.no_proxy);
}
b
}
fn apply_no_proxy(
b: reqwest::ClientBuilder,
proxy: reqwest::Proxy,
no_proxy: &Option<String>,
) -> reqwest::ClientBuilder {
match no_proxy.as_deref().map(reqwest::NoProxy::from_string) {
Some(Some(np)) => b.proxy(proxy.no_proxy(Some(np))),
_ => b.proxy(proxy),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
impl HttpMethod {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "get",
HttpMethod::Post => "post",
HttpMethod::Put => "put",
HttpMethod::Delete => "delete",
HttpMethod::Patch => "patch",
}
}
}
#[derive(Debug, Clone)]
pub enum HttpBody {
Json(serde_json::Value),
Bytes(Vec<u8>, String),
Empty,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RequestTimeout {
pub total_ms: Option<u64>,
pub first_chunk_ms: Option<u64>,
pub chunk_ms: Option<u64>,
}
impl From<TimeoutConfiguration> for RequestTimeout {
fn from(t: TimeoutConfiguration) -> Self {
Self {
total_ms: t.total_ms,
first_chunk_ms: t.first_chunk_ms,
chunk_ms: t.chunk_ms,
}
}
}
#[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>,
pub call_id: Option<String>,
pub recording_context: Option<aimux_core::recording::RecordingContext>,
}
#[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> {
auto_init_from_env();
let client = shared_client()?;
let started = Instant::now();
let (resp, attempt) =
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 = match &request.abort_signal {
Some(signal) => {
let bytes = resp.bytes();
tokio::select! {
biased;
_ = signal.cancelled() => {
record_failed_exchange(&request, attempt, started.elapsed().as_millis() as u64, "aborted after 2xx", None);
record_transport_closed(&request);
return Err(AiMuxError::Aborted);
}
b = bytes => b.map_err(|e| {
record_failed_exchange(&request, attempt, started.elapsed().as_millis() as u64, &e.to_string(), None);
record_transport_closed(&request);
AiMuxError::ApiCall(ApiCallError { message: e.to_string(), is_retryable: true, ..Default::default() })
})?,
}
}
None => resp.bytes().await.map_err(|e| {
record_failed_exchange(
&request,
attempt,
started.elapsed().as_millis() as u64,
&e.to_string(),
None,
);
record_transport_closed(&request);
AiMuxError::ApiCall(ApiCallError {
message: e.to_string(),
is_retryable: true,
..Default::default()
})
})?,
};
if body_logging_enabled() {
tracing::trace!(
target: "aimux_provider_utils::http",
body = %redact_body(&String::from_utf8_lossy(&body)),
"response_body"
);
}
if request.recording_context.is_none() {
return Ok(HttpResponse {
status,
headers,
body,
});
}
record_exchange(
&request,
aimux_core::recording::HttpExchange {
attempt,
request: to_http_record(&request),
response: Some(aimux_core::recording::ResponseRecord {
status,
headers: headers
.iter()
.map(|(k, v)| {
if aimux_core::recording::is_sensitive_key(k) {
(k.clone(), "[REDACTED]".to_string())
} else {
(k.clone(), v.clone())
}
})
.collect(),
body: {
let s = String::from_utf8_lossy(&body);
Some(truncate_utf8(&s, RECORD_BODY_CAP).to_string())
},
stream_chunks: None,
ttfb_ms: None,
}),
timing: aimux_core::recording::TimingRecord {
latency_ms: started.elapsed().as_millis() as u64,
ttfb_ms: None,
},
error: None,
finalized: true,
},
);
record_transport_closed(&request);
Ok(HttpResponse {
status,
headers,
body,
})
}
pub async fn send_stream(
request: HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
) -> Result<HttpStreamResponse, AiMuxError> {
auto_init_from_env();
let client = shared_streaming_client()?;
let started = Instant::now();
let (resp, attempt) =
send_with_retry_raw(client, &request, retry_config, error_structure).await?;
let status = resp.status().as_u16();
let headers = collect_headers(resp.headers());
tracing::debug!(
target: "aimux_provider_utils::http",
status = status,
"stream_connected"
);
let stream_rec = if request.recording_context.is_some() {
let resp_headers: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| {
if aimux_core::recording::is_sensitive_key(k) {
(k.clone(), "[REDACTED]".to_string())
} else {
(k.clone(), v.clone())
}
})
.collect();
record_exchange(
&request,
aimux_core::recording::HttpExchange {
attempt,
request: to_http_record(&request),
response: Some(aimux_core::recording::ResponseRecord {
status,
headers: resp_headers.clone(),
body: None,
stream_chunks: None,
ttfb_ms: None,
}),
timing: aimux_core::recording::TimingRecord {
latency_ms: started.elapsed().as_millis() as u64,
ttfb_ms: None,
},
error: None,
finalized: false,
},
);
Some(StreamRec {
request: request.clone(),
attempt,
status,
headers: resp_headers,
ttfb_ms: None,
body: ByteAccumulator::new(RECORD_BODY_CAP),
chunks: 0usize,
})
} else {
None
};
let request_id = ["x-request-id", "request-id"].iter().find_map(|k| {
headers
.iter()
.find(|(h, _)| h.eq_ignore_ascii_case(k))
.map(|(_, v)| v.clone())
});
let body = ObservedByteStream {
inner: resp
.bytes_stream()
.map(move |item| {
item.map_err(|e| {
AiMuxError::ApiCall(ApiCallError {
message: e.to_string(),
request_id: request_id.clone(),
is_retryable: true,
..Default::default()
})
})
})
.boxed(),
started: false,
start: started,
chunks: 0u64,
done: false,
record: stream_rec,
}
.boxed();
Ok(HttpStreamResponse {
status,
headers,
body,
})
}
struct StreamRec {
request: HttpRequest,
attempt: u32,
status: u16,
headers: Vec<(String, String)>,
ttfb_ms: Option<u64>,
body: ByteAccumulator,
chunks: usize,
}
impl StreamRec {
fn finalize(&mut self, error: Option<String>) {
let body = self.body.decode();
let response = aimux_core::recording::ResponseRecord {
status: self.status,
headers: self.headers.clone(),
body,
stream_chunks: Some(self.chunks),
ttfb_ms: self.ttfb_ms,
};
update_exchange_response(&self.request, self.attempt, response, error);
record_transport_closed(&self.request);
}
}
struct ObservedByteStream {
inner: BoxStream<'static, Result<Bytes, AiMuxError>>,
started: bool,
start: Instant,
chunks: u64,
done: bool,
record: Option<StreamRec>,
}
impl Stream for ObservedByteStream {
type Item = Result<Bytes, AiMuxError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.as_mut().get_mut();
if this.done {
return Poll::Ready(None);
}
let result = this.inner.as_mut().poll_next(cx);
match &result {
Poll::Ready(Some(Ok(bytes))) => {
if !this.started {
this.started = true;
let ttfb = this.start.elapsed().as_millis() as u64;
tracing::debug!(
target: "aimux_provider_utils::http",
ttfb_ms = ttfb,
"stream_first_byte"
);
if let Some(rec) = &mut this.record {
rec.ttfb_ms = Some(ttfb);
}
}
this.chunks += 1;
if let Some(rec) = &mut this.record {
rec.body.push(bytes);
rec.chunks = this.chunks as usize;
}
}
Poll::Ready(Some(Err(e))) => {
this.done = true;
tracing::debug!(
target: "aimux_provider_utils::http",
chunks = this.chunks,
duration_ms = this.start.elapsed().as_millis() as u64,
"stream_end"
);
if let Some(rec) = &mut this.record {
rec.finalize(Some(e.to_string()));
}
}
Poll::Ready(None) => {
this.done = true;
tracing::debug!(
target: "aimux_provider_utils::http",
chunks = this.chunks,
duration_ms = this.start.elapsed().as_millis() as u64,
"stream_end"
);
if let Some(rec) = &mut this.record {
rec.finalize(None);
}
}
Poll::Pending => {}
}
result
}
}
impl Drop for ObservedByteStream {
fn drop(&mut self) {
if self.record.is_some()
&& !self.done
&& let Some(rec) = &mut self.record
{
rec.finalize(Some("abandoned".to_string()));
}
}
}
pub async fn send_timed(
request: HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
timeout: Option<RequestTimeout>,
) -> Result<HttpResponse, AiMuxError> {
let timeout = timeout.unwrap_or_default();
validate_timeout(&timeout)?;
let fut = send(request, retry_config, error_structure);
match timeout.total_ms {
Some(0) => Err(AiMuxError::Timeout("total timeout".to_string())),
Some(ms) => Ok(tokio::time::timeout(Duration::from_millis(ms), fut)
.await
.map_err(|_| AiMuxError::Timeout(format!("total timeout after {ms}ms")))??),
None => fut.await,
}
}
pub async fn send_stream_timed(
request: HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
timeout: Option<RequestTimeout>,
) -> Result<HttpStreamResponse, AiMuxError> {
let timeout = timeout.unwrap_or_default();
validate_timeout(&timeout)?;
let start = Instant::now();
let resp = match timeout.total_ms {
Some(0) => return Err(AiMuxError::Timeout("total timeout".to_string())),
Some(ms) => tokio::time::timeout(
Duration::from_millis(ms),
send_stream(request.clone(), retry_config, error_structure),
)
.await
.map_err(|_| AiMuxError::Timeout(format!("total timeout after {ms}ms")))??,
None => send_stream(request.clone(), retry_config, error_structure).await?,
};
if timeout.total_ms.is_none()
&& timeout.first_chunk_ms.is_none()
&& timeout.chunk_ms.is_none()
&& request.abort_signal.is_none()
{
return Ok(resp);
}
Ok(HttpStreamResponse {
status: resp.status,
headers: resp.headers,
body: TimeoutBodyStream {
inner: resp.body,
start,
total_ms: timeout.total_ms,
first_chunk_ms: timeout.first_chunk_ms,
chunk_ms: timeout.chunk_ms,
abort_signal: request.abort_signal,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
}
.boxed(),
})
}
fn validate_timeout(timeout: &RequestTimeout) -> Result<(), AiMuxError> {
let now = Instant::now();
for ms in [timeout.total_ms, timeout.first_chunk_ms, timeout.chunk_ms]
.into_iter()
.flatten()
{
if now.checked_add(Duration::from_millis(ms)).is_none() {
return Err(AiMuxError::InvalidArgument(format!(
"timeout value {ms}ms is too large"
)));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutKind {
FirstChunk,
ChunkIdle,
Total,
}
impl TimeoutKind {
fn message(self) -> &'static str {
match self {
TimeoutKind::FirstChunk => "first chunk timeout",
TimeoutKind::ChunkIdle => "chunk idle timeout",
TimeoutKind::Total => "total timeout",
}
}
}
struct TimeoutBodyStream {
inner: BoxStream<'static, Result<Bytes, AiMuxError>>,
start: Instant,
total_ms: Option<u64>,
first_chunk_ms: Option<u64>,
chunk_ms: Option<u64>,
abort_signal: Option<AbortSignal>,
first: bool,
last_chunk_at: Option<Instant>,
sleep: Option<Pin<Box<tokio::time::Sleep>>>,
abort_wait: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
done: bool,
}
impl TimeoutBodyStream {
fn next_deadline(&self) -> Option<(Instant, TimeoutKind)> {
let phase = if self.first {
self.first_chunk_ms.and_then(|ms| {
self.start
.checked_add(Duration::from_millis(ms))
.map(|d| (d, TimeoutKind::FirstChunk))
})
} else {
self.chunk_ms.and_then(|ms| {
self.last_chunk_at
.and_then(|t| t.checked_add(Duration::from_millis(ms)))
.map(|d| (d, TimeoutKind::ChunkIdle))
})
};
let total = self.total_ms.and_then(|ms| {
self.start
.checked_add(Duration::from_millis(ms))
.map(|d| (d, TimeoutKind::Total))
});
match (phase, total) {
(Some((pd, pk)), Some((td, _))) => {
if pd <= td {
Some((pd, pk))
} else {
Some((td, TimeoutKind::Total))
}
}
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => {
let phase_configured = if self.first {
self.first_chunk_ms.is_some()
} else {
self.chunk_ms.is_some()
};
(phase_configured || self.total_ms.is_some())
.then_some((self.start, TimeoutKind::Total))
}
}
}
}
impl Stream for TimeoutBodyStream {
type Item = Result<Bytes, AiMuxError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.as_mut().get_mut();
if this.done {
return Poll::Ready(None);
}
if let Some(sig) = &this.abort_signal
&& sig.is_aborted()
{
this.done = true;
this.sleep = None;
return Poll::Ready(Some(Err(AiMuxError::Aborted)));
}
if this.abort_wait.is_none()
&& let Some(signal) = this.abort_signal.as_ref()
{
this.abort_wait = Some(Box::pin(signal.cancelled()));
}
let Some((deadline, kind)) = this.next_deadline() else {
if let Some(wait) = this.abort_wait.as_mut()
&& wait.as_mut().poll(cx).is_ready()
{
this.done = true;
return Poll::Ready(Some(Err(AiMuxError::Aborted)));
}
let result = this.inner.as_mut().poll_next(cx);
match &result {
Poll::Ready(Some(Ok(_))) => {
this.first = false;
this.last_chunk_at = Some(Instant::now());
}
Poll::Ready(Some(Err(_))) | Poll::Ready(None) => {
this.done = true;
}
Poll::Pending => {}
}
return result;
};
if Instant::now() >= deadline {
this.done = true;
this.sleep = None;
return Poll::Ready(Some(Err(AiMuxError::Timeout(kind.message().to_string()))));
}
let needs_reset = match &this.sleep {
Some(s) => s.deadline() != deadline.into(),
None => true,
};
if needs_reset {
this.sleep = Some(Box::pin(tokio::time::sleep_until(deadline.into())));
}
{
if let Some(wait) = this.abort_wait.as_mut()
&& wait.as_mut().poll(cx).is_ready()
{
this.done = true;
this.sleep = None;
return Poll::Ready(Some(Err(AiMuxError::Aborted)));
}
if let Some(sleep) = this.sleep.as_mut()
&& sleep.as_mut().poll(cx).is_ready()
{
this.done = true;
this.sleep = None;
return Poll::Ready(Some(Err(AiMuxError::Timeout(kind.message().to_string()))));
}
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(bytes))) => {
this.first = false;
this.last_chunk_at = Some(Instant::now());
this.sleep = None;
Poll::Ready(Some(Ok(bytes)))
}
Poll::Ready(Some(Err(e))) => {
this.done = true;
this.sleep = None;
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => {
this.done = true;
this.sleep = None;
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
}
pub async fn sleep_or_abort(
duration: Duration,
signal: Option<&AbortSignal>,
) -> Result<(), AiMuxError> {
match signal {
Some(signal) => {
tokio::select! {
biased;
_ = signal.cancelled() => Err(AiMuxError::Aborted),
_ = tokio::time::sleep(duration) => Ok(()),
}
}
None => {
tokio::time::sleep(duration).await;
Ok(())
}
}
}
const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
async fn read_error_body(
resp: reqwest::Response,
request: &HttpRequest,
) -> Result<String, AiMuxError> {
let read = async {
let mut stream = resp.bytes_stream();
let mut collected: Vec<u8> = Vec::new();
let mut total: usize = 0;
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(_) => break,
};
total = total.saturating_add(chunk.len());
if collected.len() < MAX_ERROR_BODY_BYTES {
let take = (MAX_ERROR_BODY_BYTES - collected.len()).min(chunk.len());
collected.extend_from_slice(&chunk[..take]);
}
}
let mut s = String::from_utf8_lossy(&collected).into_owned();
if total > MAX_ERROR_BODY_BYTES || s.len() > MAX_ERROR_BODY_BYTES {
let mut cut = s.len().min(MAX_ERROR_BODY_BYTES);
while !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
s.push_str(&format!("…(truncated, full error body {total} bytes)"));
}
s
};
match &request.abort_signal {
Some(signal) => {
let text = read;
tokio::select! {
biased;
_ = signal.cancelled() => Err(AiMuxError::Aborted),
t = text => Ok(t),
}
}
None => Ok(read.await),
}
}
const RECORD_BODY_CAP: usize = 1 << 20;
fn truncate_utf8(s: &str, cap: usize) -> &str {
if s.len() <= cap {
return s;
}
let mut end = cap;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
struct ByteAccumulator {
buf: Vec<u8>,
cap: usize,
truncated: bool,
}
impl ByteAccumulator {
fn new(cap: usize) -> Self {
Self {
buf: Vec::new(),
cap,
truncated: false,
}
}
fn push(&mut self, bytes: &[u8]) {
if self.truncated {
return;
}
let remaining = self.cap.saturating_sub(self.buf.len());
if remaining == 0 {
self.truncated = true;
return;
}
if bytes.len() <= remaining {
self.buf.extend_from_slice(bytes);
} else {
self.buf.extend_from_slice(&bytes[..remaining]);
self.truncated = true;
}
}
fn decode(&self) -> Option<String> {
if self.buf.is_empty() {
return None;
}
let mut s = String::from_utf8_lossy(&self.buf).into_owned();
if self.truncated {
while s.ends_with('\u{FFFD}') {
s.pop();
}
s.push_str("…(truncated)");
}
Some(s)
}
}
fn to_http_record(request: &HttpRequest) -> aimux_core::recording::HttpRecord {
use aimux_core::recording::is_sensitive_key;
let headers = request
.headers
.iter()
.map(|(k, v)| {
if is_sensitive_key(k) {
(k.clone(), "[REDACTED]".to_string())
} else {
(k.clone(), v.clone())
}
})
.collect();
let body = match &request.body {
HttpBody::Json(v) => serde_json::to_string(v).unwrap_or_default(),
HttpBody::Bytes(b, _) => String::from_utf8_lossy(b).into_owned(),
HttpBody::Empty => String::new(),
};
let body = if body.is_empty() {
None
} else {
Some(truncate_utf8(&body, RECORD_BODY_CAP).to_string())
};
aimux_core::recording::HttpRecord {
method: request.method.as_str().to_string(),
url: request.url.clone(),
headers,
body,
}
}
fn record_exchange(request: &HttpRequest, exchange: aimux_core::recording::HttpExchange) {
if let Some(ctx) = &request.recording_context {
ctx.recorder.record_exchange(&ctx.call_id, &exchange);
}
}
fn update_exchange_response(
request: &HttpRequest,
attempt: u32,
response: aimux_core::recording::ResponseRecord,
error: Option<String>,
) {
if let Some(ctx) = &request.recording_context {
ctx.recorder
.record_exchange_update(&ctx.call_id, attempt, &response, error);
}
}
fn record_transport_closed(request: &HttpRequest) {
if let Some(ctx) = &request.recording_context {
ctx.recorder.record_transport_closed(&ctx.call_id);
}
}
fn redacted_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<(String, String)> {
use aimux_core::recording::is_sensitive_key;
headers
.iter()
.map(|(k, v)| {
let key = k.to_string();
if is_sensitive_key(&key) {
(key, "[REDACTED]".to_string())
} else {
(key, v.to_str().unwrap_or("").to_string())
}
})
.collect()
}
fn error_response_record(
status: u16,
headers: Vec<(String, String)>,
body: &str,
) -> aimux_core::recording::ResponseRecord {
aimux_core::recording::ResponseRecord {
status,
headers,
body: if body.is_empty() {
None
} else {
Some(truncate_utf8(body, RECORD_BODY_CAP).to_string())
},
stream_chunks: None,
ttfb_ms: None,
}
}
fn record_failed_exchange(
request: &HttpRequest,
attempt: u32,
latency_ms: u64,
error: &str,
response: Option<aimux_core::recording::ResponseRecord>,
) {
record_exchange(
request,
aimux_core::recording::HttpExchange {
attempt,
request: to_http_record(request),
response,
timing: aimux_core::recording::TimingRecord {
latency_ms,
ttfb_ms: None,
},
error: Some(error.to_string()),
finalized: true,
},
);
}
async fn send_with_retry_raw(
client: &Client,
request: &HttpRequest,
retry_config: RetryConfig,
error_structure: &ErrorStructure,
) -> Result<(reqwest::Response, u32), AiMuxError> {
let span = tracing::debug_span!(
target: "aimux_provider_utils::http",
"http_request",
method = %request.method.as_str(),
host = %request_host(&request.url),
attempt = 0u64,
);
let _enter = span.enter();
let mut last_error = AiMuxError::Other("no attempts made".to_string());
let mut last_status: Option<u16> = None;
let mut exponential_delay_ms = retry_config.initial_delay.as_millis() as i64;
for attempt in 0..=retry_config.max_retries {
let attempt_start = Instant::now();
let resp = send_request(client, request).await;
let latency_ms = attempt_start.elapsed().as_millis() as u64;
match resp {
Ok(resp) => {
let status_code = resp.status().as_u16();
last_status = Some(status_code);
tracing::debug!(
target: "aimux_provider_utils::http",
status = status_code,
latency_ms = latency_ms,
"response"
);
if resp.status().is_success() {
return Ok((resp, attempt));
}
let request_id = ["x-request-id", "request-id"]
.iter()
.find_map(|k| resp.headers().get(*k))
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let retry_after_ms = (status_code == 429)
.then(|| {
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(),
)
})
.flatten()
.and_then(|ms| u64::try_from(ms).ok());
let resp_headers = redacted_response_headers(resp.headers());
let body = read_error_body(resp, request).await?;
let mut err = parse_provider_error(status_code, &body, error_structure);
if let AiMuxError::ApiCall(d) = &mut err {
d.request_id = request_id;
d.retry_after_ms = retry_after_ms;
}
record_failed_exchange(
request,
attempt,
latency_ms,
&err.to_string(),
Some(error_response_record(status_code, resp_headers, &body)),
);
if err.is_retryable() {
last_error = err;
} else {
return Err(err);
}
}
Err(e) => {
record_failed_exchange(request, attempt, latency_ms, &e.to_string(), None);
last_error = e;
}
}
if !last_error.is_retryable() || attempt == retry_config.max_retries {
if !matches!(last_error, AiMuxError::Aborted) {
tracing::error!(
target: "aimux_provider_utils::http",
status = last_status.unwrap_or(0),
attempts = attempt + 1,
reason = %error_variant(&last_error),
"failed"
);
}
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)
};
tracing::warn!(
target: "aimux_provider_utils::http",
attempt = attempt + 1,
max_retries = retry_config.max_retries,
status = last_status.unwrap_or(0),
delay_ms = delay_ms.max(0) as u64,
reason = %error_variant(&last_error),
"retry"
);
span.record("attempt", attempt + 1);
let delay = Duration::from_millis(delay_ms.max(0) as u64);
sleep_or_abort(delay, request.abort_signal.as_ref()).await?;
exponential_delay_ms =
exponential_delay_ms.saturating_mul(retry_config.backoff_factor as i64);
}
Err(last_error)
}
async fn send_request(
client: &Client,
request: &HttpRequest,
) -> Result<reqwest::Response, AiMuxError> {
tracing::debug!(
target: "aimux_provider_utils::http",
method = %request.method.as_str(),
url = %request_url_no_query(&request.url),
body_size = request_body_size(request),
header_count = request.headers.len(),
"request"
);
if body_logging_enabled()
&& let HttpBody::Json(value) = &request.body
{
tracing::trace!(
target: "aimux_provider_utils::http",
body = %redact_body(&value.to_string()),
"request_body"
);
}
if let Some(signal) = &request.abort_signal {
if signal.is_aborted() {
return Err(AiMuxError::Aborted);
}
let response = build_request_builder(client, request)?.send();
tokio::select! {
biased;
_ = signal.cancelled() => Err(AiMuxError::Aborted),
result = response => result.map_err(|e| AiMuxError::ApiCall(ApiCallError { message: e.to_string(), is_retryable: true, ..Default::default() })),
}
} else {
build_request_builder(client, request)?
.send()
.await
.map_err(|e| {
AiMuxError::ApiCall(ApiCallError {
message: e.to_string(),
is_retryable: true,
..Default::default()
})
})
}
}
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()
}
fn request_host(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
.unwrap_or_else(|| "unknown".to_owned())
}
fn request_url_no_query(url: &str) -> String {
match url::Url::parse(url) {
Ok(mut parsed) => {
parsed.set_query(None);
parsed.to_string()
}
Err(_) => url.split('?').next().unwrap_or(url).to_owned(),
}
}
fn request_body_size(request: &HttpRequest) -> u64 {
match &request.body {
HttpBody::Json(value) => serde_json::to_vec(value)
.map(|b| b.len() as u64)
.unwrap_or(0),
HttpBody::Bytes(bytes, _) => bytes.len() as u64,
HttpBody::Empty => 0,
}
}
fn error_variant(e: &AiMuxError) -> &'static str {
match e {
AiMuxError::ApiCall(_) => "api_call",
AiMuxError::JsonParse(_) => "json_parse",
AiMuxError::InvalidResponseData(_) => "invalid_response_data",
AiMuxError::Tool(_) => "tool",
AiMuxError::InvalidArgument(_) => "invalid_argument",
AiMuxError::InvalidPrompt(_) => "invalid_prompt",
AiMuxError::TokenExpired(_) => "token_expired",
AiMuxError::UnsupportedFunctionality(_) => "unsupported_functionality",
AiMuxError::NoSuchModel { .. } => "no_such_model",
AiMuxError::NoSuchProvider { .. } => "no_such_provider",
AiMuxError::Timeout(_) => "timeout",
AiMuxError::Aborted => "aborted",
AiMuxError::Other(_) => "other",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_client_is_stable_handle() {
let a = shared_client().expect("client init in test env");
let b = shared_client().expect("client init in test env");
assert!(std::ptr::eq(a, b));
}
#[test]
fn shared_streaming_client_is_stable_handle() {
let a = shared_streaming_client().expect("client init in test env");
let b = shared_streaming_client().expect("client init in test env");
assert!(std::ptr::eq(a, b));
}
#[test]
fn shared_and_streaming_are_distinct() {
let a = shared_client().expect("client init in test env");
let b = shared_streaming_client().expect("client init in test env");
assert!(!std::ptr::eq(a, b));
}
#[test]
fn client_init_error_is_non_retryable_api_call() {
let e = client_init_error("simulated TLS backend failure");
match e {
AiMuxError::ApiCall(call) => {
assert_eq!(call.status_code, None);
assert!(!call.is_retryable);
assert!(call.message.contains("client initialization failed"));
assert!(call.message.contains("simulated TLS backend failure"));
}
other => panic!("expected ApiCall, got {other:?}"),
}
}
#[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,
call_id: None,
recording_context: 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,
call_id: None,
recording_context: 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,
call_id: None,
recording_context: 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,
call_id: None,
recording_context: None,
};
assert!(build_request_builder(&client, &req).is_ok());
}
#[tokio::test]
async fn timeout_stream_yields_after_first_chunk_deadline() {
let inner = futures::stream::pending::<Result<Bytes, AiMuxError>>().boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: Some(200),
chunk_ms: None,
abort_signal: None,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let item = tokio::time::timeout(Duration::from_secs(5), timed.next())
.await
.expect("first-chunk deadline must fire within 5s")
.expect("stream must yield an error");
assert!(matches!(item, Err(AiMuxError::Timeout(_))));
assert!(
item.unwrap_err()
.to_string()
.contains("first chunk timeout")
);
}
#[tokio::test]
async fn timeout_stream_enforces_chunk_idle_deadline() {
let inner = futures::stream::iter(vec![Ok(Bytes::from("first"))])
.chain(futures::stream::pending::<Result<Bytes, AiMuxError>>())
.boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: None,
chunk_ms: Some(200),
abort_signal: None,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let first = timed.next().await.expect("first chunk").unwrap();
assert_eq!(&first[..], b"first");
let second = tokio::time::timeout(Duration::from_secs(5), timed.next())
.await
.expect("chunk idle timeout must fire within 5s")
.expect("stream must yield an error");
assert!(matches!(second, Err(AiMuxError::Timeout(_))));
assert!(
second
.unwrap_err()
.to_string()
.contains("chunk idle timeout")
);
}
#[tokio::test]
async fn timeout_stream_early_chunks_pass_within_budget() {
let inner = futures::stream::iter(vec![Ok(Bytes::from("a")), Ok(Bytes::from("b"))]).boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: Some(1000),
chunk_ms: Some(1000),
abort_signal: None,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let a = timed.next().await.unwrap().unwrap();
let b = timed.next().await.unwrap().unwrap();
assert_eq!(&a[..], b"a");
assert_eq!(&b[..], b"b");
assert!(timed.next().await.is_none());
}
#[tokio::test]
async fn timeout_stream_is_fused_after_timeout() {
let inner = futures::stream::pending::<Result<Bytes, AiMuxError>>().boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: Some(100),
chunk_ms: None,
abort_signal: None,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let first = tokio::time::timeout(Duration::from_secs(5), timed.next())
.await
.expect("must fire")
.expect("must be an error");
assert!(matches!(first, Err(AiMuxError::Timeout(_))));
let second = tokio::time::timeout(Duration::from_millis(500), timed.next())
.await
.expect("stream must end right after the timeout error");
assert!(second.is_none());
}
#[tokio::test]
async fn abort_wakes_pending_timeout_stream() {
let signal = AbortSignal::new();
let inner = futures::stream::pending::<Result<Bytes, AiMuxError>>().boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: None,
chunk_ms: None,
abort_signal: Some(signal.clone()),
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let handle = tokio::spawn(async move {
let item = timed.next().await;
(item, timed)
});
tokio::time::sleep(Duration::from_millis(50)).await;
signal.abort();
let (item, mut timed) = tokio::time::timeout(Duration::from_secs(2), handle)
.await
.expect("abort must wake the pending stream within 2s")
.expect("task must finish cleanly");
let item = item.expect("stream must yield an item");
assert!(matches!(item, Err(AiMuxError::Aborted)), "got {item:?}");
let next = timed.next().await;
assert!(next.is_none(), "stream must be fused after abort");
}
#[tokio::test]
async fn abort_before_first_poll_fails_fast() {
let signal = AbortSignal::new();
signal.abort();
let inner = futures::stream::pending::<Result<Bytes, AiMuxError>>().boxed();
let mut timed = TimeoutBodyStream {
inner,
start: Instant::now(),
total_ms: None,
first_chunk_ms: None,
chunk_ms: None,
abort_signal: Some(signal),
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let item = timed.next().await.expect("must yield an error");
assert!(matches!(item, Err(AiMuxError::Aborted)));
assert!(
timed.next().await.is_none(),
"stream must be fused after abort"
);
}
#[test]
fn validate_timeout_rejects_overflowing_values() {
let now = Instant::now();
let huge = RequestTimeout {
total_ms: Some(u64::MAX),
..Default::default()
};
match now.checked_add(Duration::from_millis(u64::MAX)) {
None => {
let err = validate_timeout(&huge).unwrap_err();
assert!(matches!(err, AiMuxError::InvalidArgument(_)));
}
Some(_) => {
assert!(validate_timeout(&huge).is_ok());
}
}
assert!(validate_timeout(&RequestTimeout::default()).is_ok());
}
#[test]
fn next_deadline_kind_selection() {
let now = Instant::now();
let base = TimeoutBodyStream {
inner: futures::stream::pending().boxed(),
start: now,
total_ms: Some(10_000),
first_chunk_ms: Some(1_000),
chunk_ms: None,
abort_signal: None,
first: true,
last_chunk_at: None,
sleep: None,
abort_wait: None,
done: false,
};
let (d, k) = base.next_deadline().unwrap();
assert_eq!(k, TimeoutKind::FirstChunk);
assert_eq!(d, now + Duration::from_millis(1_000));
let s2 = TimeoutBodyStream {
total_ms: Some(500),
first_chunk_ms: Some(1_000),
..base
};
let (d2, k2) = s2.next_deadline().unwrap();
assert_eq!(k2, TimeoutKind::Total);
assert_eq!(d2, now + Duration::from_millis(500));
}
#[test]
fn byte_accumulator_preserves_multibyte_across_chunk_split() {
let mut acc = ByteAccumulator::new(RECORD_BODY_CAP);
let ch = "中";
acc.push(&ch.as_bytes()[..1]); acc.push(&ch.as_bytes()[1..]); let decoded = acc.decode().unwrap();
assert_eq!(decoded, "中", "跨 chunk 多字节字符必须完整保留");
assert!(!decoded.contains('\u{FFFD}'), "不得出现替换符");
}
#[test]
fn byte_accumulator_preserves_emoji_across_chunk_split() {
let mut acc = ByteAccumulator::new(RECORD_BODY_CAP);
let b = "😀".as_bytes();
acc.push(&b[..2]);
acc.push(&b[2..]);
let decoded = acc.decode().unwrap();
assert_eq!(decoded, "😀");
assert!(!decoded.contains('\u{FFFD}'), "不得出现替换符");
}
#[test]
fn byte_accumulator_truncates_at_cap_and_marks() {
let mut acc = ByteAccumulator::new(4);
acc.push(b"0123456789");
let decoded = acc.decode().unwrap();
assert_eq!(decoded, "0123…(truncated)");
}
#[test]
fn byte_accumulator_truncates_multibyte_without_dangling_replacement() {
let mut acc = ByteAccumulator::new(4);
acc.push("中文".as_bytes());
let decoded = acc.decode().unwrap();
assert!(decoded.ends_with("…(truncated)"), "应标记截断: {decoded}");
assert!(
!decoded.contains('\u{FFFD}'),
"截断处不得残留替换符: {decoded}"
);
assert!(decoded.starts_with("中"), "完整字符保留: {decoded}");
}
#[test]
fn byte_accumulator_empty_decodes_to_none() {
let acc = ByteAccumulator::new(RECORD_BODY_CAP);
assert!(acc.decode().is_none());
}
#[test]
fn proxy_config_default_is_no_proxy() {
let config = ProxyConfig::default();
assert!(config.http_url.is_none());
assert!(config.https_url.is_none());
assert!(config.all_url.is_none());
assert!(config.no_proxy.is_none());
}
#[test]
fn apply_proxy_with_all_url_sets_http_and_https() {
let config = ProxyConfig {
all_url: Some("http://proxy.example:8080".into()),
..Default::default()
};
let _ = apply_proxy(reqwest::Client::builder(), &config);
}
#[test]
fn apply_proxy_with_no_proxy_whitelist_builds() {
let config = ProxyConfig {
https_url: Some("http://proxy.example:8080".into()),
no_proxy: Some("localhost,127.0.0.1,.internal.team".into()),
..Default::default()
};
let _ = apply_proxy(reqwest::Client::builder(), &config);
}
#[test]
fn apply_proxy_default_is_noop() {
let config = ProxyConfig::default();
let _ = apply_proxy(reqwest::Client::builder(), &config);
}
#[test]
fn apply_proxy_invalid_url_is_silently_skipped() {
let config = ProxyConfig {
http_url: Some("not-a-url".into()),
..Default::default()
};
let _ = apply_proxy(reqwest::Client::builder(), &config);
}
#[test]
fn init_proxy_returns_true_first_time() {
let result = init_proxy(ProxyConfig::default());
let _ = result;
}
}