Skip to content

Commit 6baea78

Browse files
kevinnftenowdev
authored andcommitted
feat(http): add timeouts and identifying user-agent to all provider HTTP calls
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/<version>' 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.
1 parent 693bf3d commit 6baea78

6 files changed

Lines changed: 103 additions & 16 deletions

File tree

src-tauri/src/agents/runner.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ impl AgentRunner {
11241124
"stream": true,
11251125
});
11261126

1127-
let client = reqwest::Client::new();
1127+
let client = crate::services::http_client::streaming_client()?;
11281128
let mut request = client
11291129
.post(endpoint)
11301130
.header(CONTENT_TYPE, "application/json")
@@ -1207,7 +1207,7 @@ impl AgentRunner {
12071207
format!("{}/messages", base_url.trim_end_matches('/'))
12081208
};
12091209

1210-
let client = reqwest::Client::new();
1210+
let client = crate::services::http_client::streaming_client()?;
12111211
let mut request = client
12121212
.post(&endpoint)
12131213
.header(CONTENT_TYPE, "application/json")

src-tauri/src/services/chat_service.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::{
1717
models::Message,
1818
};
1919

20-
use super::{now_rfc3339, provider_service};
20+
use super::{http_client, now_rfc3339, provider_service};
2121

2222
/// Keywords that trigger full visual/preview system prompt injection
2323
const VISUAL_KEYWORDS: &[&str] = &[
@@ -334,7 +334,7 @@ async fn send_openai_compatible(
334334
on_token: &Channel<String>,
335335
cancel_token: &CancellationToken,
336336
) -> AppResult<String> {
337-
let client = reqwest::Client::new();
337+
let client = http_client::streaming_client()?;
338338
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
339339

340340
let messages: Vec<Value> = history
@@ -413,7 +413,7 @@ async fn send_anthropic(
413413
on_token: &Channel<String>,
414414
cancel_token: &CancellationToken,
415415
) -> AppResult<String> {
416-
let client = reqwest::Client::new();
416+
let client = http_client::streaming_client()?;
417417

418418
let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) =
419419
history.iter().partition(|m| m.role == "system");
@@ -836,7 +836,7 @@ async fn generate_title_openai(
836836
model: &str,
837837
messages: &[Value],
838838
) -> AppResult<String> {
839-
let client = reqwest::Client::new();
839+
let client = http_client::request_client()?;
840840
let endpoint = format!(
841841
"{}/chat/completions",
842842
provider.base_url.trim_end_matches('/')
@@ -909,7 +909,7 @@ async fn generate_title_anthropic(
909909
format!("{}/messages", provider.base_url.trim_end_matches('/'))
910910
};
911911

912-
let client = reqwest::Client::new();
912+
let client = http_client::request_client()?;
913913
let mut request = client
914914
.post(&endpoint)
915915
.header(CONTENT_TYPE, "application/json")
@@ -1022,7 +1022,7 @@ pub async fn generate_excalidraw(
10221022

10231023
messages.push(serde_json::json!({"role": "user", "content": prompt}));
10241024

1025-
let client = reqwest::Client::new();
1025+
let client = http_client::request_client()?;
10261026
let endpoint = format!("{}/chat/completions", provider.base_url.trim_end_matches('/'));
10271027

10281028
let payload = serde_json::json!({
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! Shared HTTP client builder for outbound LLM provider requests.
2+
//!
3+
//! All provider-facing HTTP calls (chat completions, model listings,
4+
//! title generation, agent tool runs) go through these constructors.
5+
//! This guarantees:
6+
//!
7+
//! - **Connect timeout** so a dead provider host fails fast (10s).
8+
//! - **Streaming-aware request timeouts** — streaming endpoints get a
9+
//! long ceiling (10 min) so SSE doesn't get cut, while non-streaming
10+
//! calls get a sane upper bound (2 min).
11+
//! - **Read timeout** to detect stalled streams between chunks (60s).
12+
//! - **Identifying User-Agent** so providers (and the user's own
13+
//! proxy/firewall) can attribute traffic to the app.
14+
//!
15+
//! Without this, `reqwest::Client::new()` produces a client with no
16+
//! timeouts at all — a network blip or a silently-rate-limited provider
17+
//! freezes the agent indefinitely.
18+
19+
use std::time::Duration;
20+
21+
use reqwest::Client;
22+
23+
use crate::error::{AppError, AppResult};
24+
25+
const USER_AGENT: &str = concat!("enowX-Coder/", env!("CARGO_PKG_VERSION"));
26+
27+
/// Connect timeout for all outbound HTTP — applies to TCP + TLS handshake.
28+
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29+
30+
/// Per-chunk read timeout for streaming responses. If we don't see a byte
31+
/// from the upstream provider in this window, the stream is considered dead.
32+
const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(60);
33+
34+
/// Total request timeout for non-streaming calls (model listings,
35+
/// title generation, etc.). Generous, but bounded.
36+
const NON_STREAMING_TIMEOUT: Duration = Duration::from_secs(120);
37+
38+
/// Hard upper bound for streaming requests. SSE streams shouldn't outlast
39+
/// this — if they do, something is wrong upstream.
40+
const STREAMING_TIMEOUT: Duration = Duration::from_secs(600);
41+
42+
/// Build the shared `reqwest::Client` for streaming LLM responses.
43+
///
44+
/// Keeps a long total ceiling so multi-minute completions can finish,
45+
/// but still bounds connect + per-read so dead connections fail fast.
46+
pub fn streaming_client() -> AppResult<Client> {
47+
Client::builder()
48+
.user_agent(USER_AGENT)
49+
.connect_timeout(CONNECT_TIMEOUT)
50+
.read_timeout(STREAM_READ_TIMEOUT)
51+
.timeout(STREAMING_TIMEOUT)
52+
.build()
53+
.map_err(|e| AppError::Internal(format!("Failed to build streaming HTTP client: {e}")))
54+
}
55+
56+
/// Build the shared `reqwest::Client` for short, non-streaming requests
57+
/// (listing models, generating titles, single-shot completions).
58+
pub fn request_client() -> AppResult<Client> {
59+
Client::builder()
60+
.user_agent(USER_AGENT)
61+
.connect_timeout(CONNECT_TIMEOUT)
62+
.timeout(NON_STREAMING_TIMEOUT)
63+
.build()
64+
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {e}")))
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn streaming_client_builds() {
73+
let client = streaming_client();
74+
assert!(client.is_ok(), "streaming_client should build cleanly");
75+
}
76+
77+
#[test]
78+
fn request_client_builds() {
79+
let client = request_client();
80+
assert!(client.is_ok(), "request_client should build cleanly");
81+
}
82+
83+
#[test]
84+
fn user_agent_includes_version() {
85+
assert!(USER_AGENT.starts_with("enowX-Coder/"));
86+
assert!(USER_AGENT.len() > "enowX-Coder/".len());
87+
}
88+
}

src-tauri/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod agent_service;
22
pub mod chat_service;
33
pub mod drawing_service;
4+
pub mod http_client;
45
pub mod model_service;
56
pub mod project_service;
67
pub mod provider_model_service;

src-tauri/src/services/model_service.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
use reqwest::Client;
21
use serde::Deserialize;
32

4-
use crate::{
5-
error::{AppError, AppResult},
6-
models::Provider,
7-
};
3+
use crate::error::{AppError, AppResult};
4+
use crate::models::Provider;
5+
use crate::services::http_client;
86

97
#[derive(Debug, Deserialize)]
108
struct ModelList {
@@ -56,7 +54,7 @@ pub async fn list_models(provider: &Provider) -> AppResult<Vec<String>> {
5654

5755
async fn fetch_openai_models(base_url: &str, api_key: Option<&str>) -> AppResult<Vec<String>> {
5856
let url = format!("{}/models", base_url.trim_end_matches('/'));
59-
let client = Client::new();
57+
let client = http_client::request_client()?;
6058
let mut req = client.get(&url);
6159

6260
if let Some(key) = api_key {
@@ -98,7 +96,7 @@ async fn fetch_anthropic_models(
9896
use_x_api_key: bool,
9997
) -> AppResult<Vec<String>> {
10098
let url = format!("{}/models", base_url.trim_end_matches('/'));
101-
let client = Client::new();
99+
let client = http_client::request_client()?;
102100
let mut req = client
103101
.get(&url)
104102
.header("anthropic-version", "2023-06-01");

src-tauri/src/tools/executor.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ impl ToolExecutor {
477477
let query = input["query"]
478478
.as_str()
479479
.ok_or_else(|| AppError::Validation("Missing 'query' field".to_string()))?;
480-
let client = reqwest::Client::new();
480+
let client = crate::services::http_client::request_client()?;
481481
let url = format!(
482482
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
483483
urlencoding::encode(query)

0 commit comments

Comments
 (0)