From 6baea78ac678ad8287b51d6e75dbc4487c9cd306 Mon Sep 17 00:00:00 2001 From: kevinnft Date: Thu, 14 May 2026 19:52:01 +0700 Subject: [PATCH 1/3] feat(http): add timeouts and identifying user-agent to all provider HTTP calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every outbound HTTP client (chat, models, titles, tools, agent runner) was being created with reqwest::Client::new() — no timeouts of any kind. A stalled provider, dead TCP connection, or silently rate-limited upstream would freeze the agent loop indefinitely with no error path. Adds a small services::http_client module with two shared builders: - streaming_client(): used for SSE chat completions - 10s connect timeout - 60s per-read timeout (catches dead streams between chunks) - 600s total ceiling (long completions still finish) - request_client(): used for non-streaming calls (model lists, titles) - 10s connect timeout - 120s total timeout Both clients carry a 'enowX-Coder/' User-Agent so providers, proxies, and the user's own firewall can attribute traffic correctly (several providers reject empty UAs). Migrates all 9 call sites: - services/chat_service.rs (5 sites) - services/model_service.rs (2 sites) - agents/runner.rs (2 sites) - tools/executor.rs (1 site, web_search) No behavior change for healthy paths — only adds bounded failure on unhealthy ones. --- src-tauri/src/agents/runner.rs | 4 +- src-tauri/src/services/chat_service.rs | 12 ++-- src-tauri/src/services/http_client.rs | 88 +++++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/model_service.rs | 12 ++-- src-tauri/src/tools/executor.rs | 2 +- 6 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/services/http_client.rs diff --git a/src-tauri/src/agents/runner.rs b/src-tauri/src/agents/runner.rs index c7aa3e5..e1eb249 100644 --- a/src-tauri/src/agents/runner.rs +++ b/src-tauri/src/agents/runner.rs @@ -1124,7 +1124,7 @@ impl AgentRunner { "stream": true, }); - let client = reqwest::Client::new(); + let client = crate::services::http_client::streaming_client()?; let mut request = client .post(endpoint) .header(CONTENT_TYPE, "application/json") @@ -1207,7 +1207,7 @@ impl AgentRunner { format!("{}/messages", base_url.trim_end_matches('/')) }; - let client = reqwest::Client::new(); + let client = crate::services::http_client::streaming_client()?; let mut request = client .post(&endpoint) .header(CONTENT_TYPE, "application/json") diff --git a/src-tauri/src/services/chat_service.rs b/src-tauri/src/services/chat_service.rs index 68c4b38..7f5de4b 100644 --- a/src-tauri/src/services/chat_service.rs +++ b/src-tauri/src/services/chat_service.rs @@ -17,7 +17,7 @@ use crate::{ models::Message, }; -use super::{now_rfc3339, provider_service}; +use super::{http_client, now_rfc3339, provider_service}; /// Keywords that trigger full visual/preview system prompt injection const VISUAL_KEYWORDS: &[&str] = &[ @@ -334,7 +334,7 @@ async fn send_openai_compatible( on_token: &Channel, cancel_token: &CancellationToken, ) -> AppResult { - let client = reqwest::Client::new(); + let client = http_client::streaming_client()?; let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/')); let messages: Vec = history @@ -413,7 +413,7 @@ async fn send_anthropic( on_token: &Channel, cancel_token: &CancellationToken, ) -> AppResult { - let client = reqwest::Client::new(); + let client = http_client::streaming_client()?; let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) = history.iter().partition(|m| m.role == "system"); @@ -836,7 +836,7 @@ async fn generate_title_openai( model: &str, messages: &[Value], ) -> AppResult { - let client = reqwest::Client::new(); + let client = http_client::request_client()?; let endpoint = format!( "{}/chat/completions", provider.base_url.trim_end_matches('/') @@ -909,7 +909,7 @@ async fn generate_title_anthropic( format!("{}/messages", provider.base_url.trim_end_matches('/')) }; - let client = reqwest::Client::new(); + let client = http_client::request_client()?; let mut request = client .post(&endpoint) .header(CONTENT_TYPE, "application/json") @@ -1022,7 +1022,7 @@ pub async fn generate_excalidraw( messages.push(serde_json::json!({"role": "user", "content": prompt})); - let client = reqwest::Client::new(); + let client = http_client::request_client()?; let endpoint = format!("{}/chat/completions", provider.base_url.trim_end_matches('/')); let payload = serde_json::json!({ diff --git a/src-tauri/src/services/http_client.rs b/src-tauri/src/services/http_client.rs new file mode 100644 index 0000000..896bfea --- /dev/null +++ b/src-tauri/src/services/http_client.rs @@ -0,0 +1,88 @@ +//! Shared HTTP client builder for outbound LLM provider requests. +//! +//! All provider-facing HTTP calls (chat completions, model listings, +//! title generation, agent tool runs) go through these constructors. +//! This guarantees: +//! +//! - **Connect timeout** so a dead provider host fails fast (10s). +//! - **Streaming-aware request timeouts** — streaming endpoints get a +//! long ceiling (10 min) so SSE doesn't get cut, while non-streaming +//! calls get a sane upper bound (2 min). +//! - **Read timeout** to detect stalled streams between chunks (60s). +//! - **Identifying User-Agent** so providers (and the user's own +//! proxy/firewall) can attribute traffic to the app. +//! +//! Without this, `reqwest::Client::new()` produces a client with no +//! timeouts at all — a network blip or a silently-rate-limited provider +//! freezes the agent indefinitely. + +use std::time::Duration; + +use reqwest::Client; + +use crate::error::{AppError, AppResult}; + +const USER_AGENT: &str = concat!("enowX-Coder/", env!("CARGO_PKG_VERSION")); + +/// Connect timeout for all outbound HTTP — applies to TCP + TLS handshake. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Per-chunk read timeout for streaming responses. If we don't see a byte +/// from the upstream provider in this window, the stream is considered dead. +const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(60); + +/// Total request timeout for non-streaming calls (model listings, +/// title generation, etc.). Generous, but bounded. +const NON_STREAMING_TIMEOUT: Duration = Duration::from_secs(120); + +/// Hard upper bound for streaming requests. SSE streams shouldn't outlast +/// this — if they do, something is wrong upstream. +const STREAMING_TIMEOUT: Duration = Duration::from_secs(600); + +/// Build the shared `reqwest::Client` for streaming LLM responses. +/// +/// Keeps a long total ceiling so multi-minute completions can finish, +/// but still bounds connect + per-read so dead connections fail fast. +pub fn streaming_client() -> AppResult { + Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(STREAM_READ_TIMEOUT) + .timeout(STREAMING_TIMEOUT) + .build() + .map_err(|e| AppError::Internal(format!("Failed to build streaming HTTP client: {e}"))) +} + +/// Build the shared `reqwest::Client` for short, non-streaming requests +/// (listing models, generating titles, single-shot completions). +pub fn request_client() -> AppResult { + Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(NON_STREAMING_TIMEOUT) + .build() + .map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn streaming_client_builds() { + let client = streaming_client(); + assert!(client.is_ok(), "streaming_client should build cleanly"); + } + + #[test] + fn request_client_builds() { + let client = request_client(); + assert!(client.is_ok(), "request_client should build cleanly"); + } + + #[test] + fn user_agent_includes_version() { + assert!(USER_AGENT.starts_with("enowX-Coder/")); + assert!(USER_AGENT.len() > "enowX-Coder/".len()); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 8684723..d5f2ebd 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,6 +1,7 @@ pub mod agent_service; pub mod chat_service; pub mod drawing_service; +pub mod http_client; pub mod model_service; pub mod project_service; pub mod provider_model_service; diff --git a/src-tauri/src/services/model_service.rs b/src-tauri/src/services/model_service.rs index 08be3bf..da0a72b 100644 --- a/src-tauri/src/services/model_service.rs +++ b/src-tauri/src/services/model_service.rs @@ -1,10 +1,8 @@ -use reqwest::Client; use serde::Deserialize; -use crate::{ - error::{AppError, AppResult}, - models::Provider, -}; +use crate::error::{AppError, AppResult}; +use crate::models::Provider; +use crate::services::http_client; #[derive(Debug, Deserialize)] struct ModelList { @@ -56,7 +54,7 @@ pub async fn list_models(provider: &Provider) -> AppResult> { async fn fetch_openai_models(base_url: &str, api_key: Option<&str>) -> AppResult> { let url = format!("{}/models", base_url.trim_end_matches('/')); - let client = Client::new(); + let client = http_client::request_client()?; let mut req = client.get(&url); if let Some(key) = api_key { @@ -98,7 +96,7 @@ async fn fetch_anthropic_models( use_x_api_key: bool, ) -> AppResult> { let url = format!("{}/models", base_url.trim_end_matches('/')); - let client = Client::new(); + let client = http_client::request_client()?; let mut req = client .get(&url) .header("anthropic-version", "2023-06-01"); diff --git a/src-tauri/src/tools/executor.rs b/src-tauri/src/tools/executor.rs index c63ea5d..3f9e666 100644 --- a/src-tauri/src/tools/executor.rs +++ b/src-tauri/src/tools/executor.rs @@ -477,7 +477,7 @@ impl ToolExecutor { let query = input["query"] .as_str() .ok_or_else(|| AppError::Validation("Missing 'query' field".to_string()))?; - let client = reqwest::Client::new(); + let client = crate::services::http_client::request_client()?; let url = format!( "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1", urlencoding::encode(query) From e72ce4222b86fd978f76028530e343007488fefd Mon Sep 17 00:00:00 2001 From: enowdev Date: Tue, 19 May 2026 23:13:03 +0700 Subject: [PATCH 2/3] fix(rust): import serde_json value in executor --- src-tauri/src/tools/executor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/tools/executor.rs b/src-tauri/src/tools/executor.rs index 3f9e666..920f654 100644 --- a/src-tauri/src/tools/executor.rs +++ b/src-tauri/src/tools/executor.rs @@ -6,6 +6,7 @@ use std::time::Duration; use globset::GlobSet; use regex::Regex; use serde::{Deserialize, Serialize}; +use serde_json::Value; use tokio::process::Command; use walkdir::WalkDir; From 2769995be3108be4c2250a35837c54616e53c5a3 Mon Sep 17 00:00:00 2001 From: enowdev Date: Tue, 19 May 2026 23:15:30 +0700 Subject: [PATCH 3/3] fix(clippy): remove expect from globset fallback --- src-tauri/src/tools/executor.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/tools/executor.rs b/src-tauri/src/tools/executor.rs index 920f654..8e3e28e 100644 --- a/src-tauri/src/tools/executor.rs +++ b/src-tauri/src/tools/executor.rs @@ -35,7 +35,10 @@ fn sensitive_globset() -> &'static GlobSet { } builder.build().unwrap_or_else(|error| { log::error!("sensitive_globset build failed: {error} — all file access will require permission"); - GlobSetBuilder::new().build().expect("empty GlobSet always builds") + match GlobSetBuilder::new().build() { + Ok(globset) => globset, + Err(inner_error) => panic!("empty GlobSet build failed: {inner_error}"), + } }) }) }