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 aimux_core::AiMuxError;
use aimux_core::options::TimeoutConfiguration;
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, 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>,
}
#[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 = match &request.abort_signal {
Some(signal) => {
let bytes = resp.bytes();
tokio::select! {
biased;
_ = signal.cancelled() => return Err(AiMuxError::Aborted),
b = bytes => b.map_err(|e| AiMuxError::Http(e.to_string()))?,
}
}
None => 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,
})
}
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,
}
}
}
}
async fn read_error_body(
resp: reqwest::Response,
request: &HttpRequest,
) -> Result<String, AiMuxError> {
match &request.abort_signal {
Some(signal) => {
let text = resp.text();
tokio::select! {
biased;
_ = signal.cancelled() => Err(AiMuxError::Aborted),
t = text => Ok(t.unwrap_or_default()),
}
}
None => Ok(resp.text().await.unwrap_or_default()),
}
}
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 _ = read_error_body(resp, request).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 = read_error_body(resp, request).await?;
last_error = AiMuxError::ApiCall(format!("HTTP {}: {}", status_code, body));
} else {
let body = read_error_body(resp, request).await?;
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)
};
let delay = Duration::from_millis(delay_ms.max(0) as u64);
match &request.abort_signal {
Some(signal) => {
tokio::select! {
biased;
_ = signal.cancelled() => return Err(AiMuxError::Aborted),
_ = tokio::time::sleep(delay) => {}
}
}
None => tokio::time::sleep(delay).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> {
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::Http(e.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());
}
#[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));
}
}