use std::sync::OnceLock;
use std::time::Duration;
use thiserror::Error;
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
pub const READ_CHUNK_BYTES: usize = 16 * 1024;
const CLOUD_TIMEOUT_SECS: u64 = 60;
const LOCAL_TIMEOUT_SECS: u64 = 600;
const CONNECT_TIMEOUT_SECS: u64 = 10;
const POOL_IDLE_TIMEOUT_SECS: u64 = 90;
const TCP_KEEPALIVE_SECS: u64 = 60;
const CLOUD_PROVIDERS: &[&str] = &["openai", "anthropic", "google", "xai"];
const LOCAL_PROVIDERS: &[&str] = &["ollama", "lmstudio", "localhost"];
#[derive(Debug, Error)]
pub enum HttpError {
#[error("http request to {provider} timed out after {timeout_secs}s")]
Timeout {
provider: String,
timeout_secs: u64,
},
#[error("http connection to {provider} failed: {detail}")]
Connect {
provider: String,
detail: String,
},
#[error("http request to {provider} failed: {detail}")]
Request {
provider: String,
detail: String,
},
#[error("failed to build http client: {0}")]
Builder(String),
}
#[derive(Debug, Clone, Copy)]
pub struct TimeoutConfig {
pub request_timeout: Duration,
pub connect_timeout: Duration,
pub pool_idle_timeout: Duration,
pub tcp_keepalive: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(CLOUD_TIMEOUT_SECS),
connect_timeout: Duration::from_secs(CONNECT_TIMEOUT_SECS),
pool_idle_timeout: Duration::from_secs(POOL_IDLE_TIMEOUT_SECS),
tcp_keepalive: Duration::from_secs(TCP_KEEPALIVE_SECS),
}
}
}
pub fn is_local_provider(base_url: &str) -> bool {
let lower = base_url.to_ascii_lowercase();
let authority = lower.split("://").nth(1).unwrap_or(&lower);
let host = authority.split('/').next().unwrap_or(authority);
host.starts_with("localhost")
|| host.starts_with("127.0.0.1")
|| host.starts_with("0.0.0.0")
|| host.starts_with("[::1]")
|| host.starts_with("::1")
|| host.starts_with("ollama")
|| host.starts_with("lmstudio")
}
fn env_timeout_override() -> Option<Duration> {
std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS")
.or_else(|_| std::env::var("RX4_HTTP_TIMEOUT_SECS"))
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.map(Duration::from_secs)
}
pub fn timeout_for_provider(provider: &str) -> Duration {
if let Some(override_) = env_timeout_override() {
return override_;
}
let lower = provider.to_ascii_lowercase();
if CLOUD_PROVIDERS.contains(&lower.as_str()) {
Duration::from_secs(CLOUD_TIMEOUT_SECS)
} else if LOCAL_PROVIDERS.contains(&lower.as_str()) || is_local_provider(provider) {
Duration::from_secs(LOCAL_TIMEOUT_SECS)
} else {
Duration::from_secs(CLOUD_TIMEOUT_SECS)
}
}
fn build_client(config: &TimeoutConfig) -> reqwest::Client {
reqwest::Client::builder()
.timeout(config.request_timeout)
.connect_timeout(config.connect_timeout)
.pool_idle_timeout(config.pool_idle_timeout)
.tcp_keepalive(config.tcp_keepalive)
.http1_only()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
pub struct HttpClient {
client: reqwest::Client,
timeout_config: TimeoutConfig,
provider: String,
}
impl HttpClient {
pub fn new() -> Self {
Self::with_provider("openai")
}
pub fn with_provider(provider: &str) -> Self {
let request_timeout = timeout_for_provider(provider);
let timeout_config = TimeoutConfig {
request_timeout,
..TimeoutConfig::default()
};
let client = build_client(&timeout_config);
Self {
client,
timeout_config,
provider: provider.to_string(),
}
}
pub fn with_timeout_config(provider: &str, timeout_config: TimeoutConfig) -> Self {
let client = build_client(&timeout_config);
Self {
client,
timeout_config,
provider: provider.to_string(),
}
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
pub fn timeout_config(&self) -> TimeoutConfig {
self.timeout_config
}
pub fn provider(&self) -> &str {
&self.provider
}
}
impl Default for HttpClient {
fn default() -> Self {
Self::new()
}
}
static GLOBAL_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
pub fn global_client() -> &'static reqwest::Client {
GLOBAL_CLIENT.get_or_init(|| build_client(&TimeoutConfig::default()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn detects_local_providers() {
assert!(is_local_provider("http://localhost:11434/v1"));
assert!(is_local_provider("http://127.0.0.1:11434/v1"));
assert!(is_local_provider("http://0.0.0.0:8080"));
assert!(is_local_provider("http://[::1]:11434/v1"));
assert!(is_local_provider("http://ollama:11434/v1"));
assert!(is_local_provider("http://lmstudio:1234/v1"));
assert!(is_local_provider("localhost:11434"));
assert!(is_local_provider("OLLAMA"));
}
#[test]
fn detects_remote_providers() {
assert!(!is_local_provider("https://api.openai.com/v1"));
assert!(!is_local_provider("https://api.anthropic.com/v1"));
assert!(!is_local_provider(
"https://generativelanguage.googleapis.com"
));
assert!(!is_local_provider("https://api.x.ai/v1"));
}
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(
config.request_timeout,
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
assert_eq!(
config.connect_timeout,
Duration::from_secs(CONNECT_TIMEOUT_SECS)
);
assert_eq!(
config.pool_idle_timeout,
Duration::from_secs(POOL_IDLE_TIMEOUT_SECS)
);
assert_eq!(
config.tcp_keepalive,
Duration::from_secs(TCP_KEEPALIVE_SECS)
);
}
#[test]
fn cloud_provider_timeout() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_pi = std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS").ok();
let saved_rx4 = std::env::var("RX4_HTTP_TIMEOUT_SECS").ok();
std::env::remove_var("PI_HTTP_REQUEST_TIMEOUT_SECS");
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
assert_eq!(
timeout_for_provider("openai"),
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("anthropic"),
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("google"),
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("xai"),
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
restore_env("PI_HTTP_REQUEST_TIMEOUT_SECS", saved_pi);
restore_env("RX4_HTTP_TIMEOUT_SECS", saved_rx4);
}
#[test]
fn local_provider_timeout() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_pi = std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS").ok();
let saved_rx4 = std::env::var("RX4_HTTP_TIMEOUT_SECS").ok();
std::env::remove_var("PI_HTTP_REQUEST_TIMEOUT_SECS");
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
assert_eq!(
timeout_for_provider("ollama"),
Duration::from_secs(LOCAL_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("lmstudio"),
Duration::from_secs(LOCAL_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("localhost"),
Duration::from_secs(LOCAL_TIMEOUT_SECS)
);
assert_eq!(
timeout_for_provider("http://127.0.0.1:11434"),
Duration::from_secs(LOCAL_TIMEOUT_SECS)
);
restore_env("PI_HTTP_REQUEST_TIMEOUT_SECS", saved_pi);
restore_env("RX4_HTTP_TIMEOUT_SECS", saved_rx4);
}
#[test]
fn env_override_timeout() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_pi = std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS").ok();
let saved_rx4 = std::env::var("RX4_HTTP_TIMEOUT_SECS").ok();
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
std::env::set_var("PI_HTTP_REQUEST_TIMEOUT_SECS", "42");
assert_eq!(timeout_for_provider("openai"), Duration::from_secs(42));
assert_eq!(timeout_for_provider("ollama"), Duration::from_secs(42));
std::env::remove_var("PI_HTTP_REQUEST_TIMEOUT_SECS");
std::env::set_var("RX4_HTTP_TIMEOUT_SECS", "7");
assert_eq!(timeout_for_provider("openai"), Duration::from_secs(7));
assert_eq!(timeout_for_provider("ollama"), Duration::from_secs(7));
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
restore_env("PI_HTTP_REQUEST_TIMEOUT_SECS", saved_pi);
restore_env("RX4_HTTP_TIMEOUT_SECS", saved_rx4);
}
#[test]
fn global_client_is_cached() {
let a = global_client();
let b = global_client();
assert!(
std::ptr::eq(a, b),
"global_client should return the same pointer"
);
}
#[test]
fn http_client_creation() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_pi = std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS").ok();
let saved_rx4 = std::env::var("RX4_HTTP_TIMEOUT_SECS").ok();
std::env::remove_var("PI_HTTP_REQUEST_TIMEOUT_SECS");
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
let client = HttpClient::new();
assert_eq!(client.provider(), "openai");
assert_eq!(
client.timeout_config().request_timeout,
Duration::from_secs(CLOUD_TIMEOUT_SECS)
);
let _ = client.client();
restore_env("PI_HTTP_REQUEST_TIMEOUT_SECS", saved_pi);
restore_env("RX4_HTTP_TIMEOUT_SECS", saved_rx4);
}
#[test]
fn http_client_with_local_provider() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_pi = std::env::var("PI_HTTP_REQUEST_TIMEOUT_SECS").ok();
let saved_rx4 = std::env::var("RX4_HTTP_TIMEOUT_SECS").ok();
std::env::remove_var("PI_HTTP_REQUEST_TIMEOUT_SECS");
std::env::remove_var("RX4_HTTP_TIMEOUT_SECS");
let client = HttpClient::with_provider("ollama");
assert_eq!(client.provider(), "ollama");
assert_eq!(
client.timeout_config().request_timeout,
Duration::from_secs(LOCAL_TIMEOUT_SECS)
);
restore_env("PI_HTTP_REQUEST_TIMEOUT_SECS", saved_pi);
restore_env("RX4_HTTP_TIMEOUT_SECS", saved_rx4);
}
#[test]
fn http_client_with_explicit_timeout_config() {
let config = TimeoutConfig {
request_timeout: Duration::from_secs(120),
connect_timeout: Duration::from_secs(5),
pool_idle_timeout: Duration::from_secs(30),
tcp_keepalive: Duration::from_secs(15),
};
let client = HttpClient::with_timeout_config("custom", config);
assert_eq!(client.provider(), "custom");
assert_eq!(
client.timeout_config().request_timeout,
Duration::from_secs(120)
);
assert_eq!(
client.timeout_config().connect_timeout,
Duration::from_secs(5)
);
}
fn restore_env(key: &str, value: Option<String>) {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}