Skip to content

Commit 0872734

Browse files
authored
Merge pull request #27 from kevinnft/fix/http-client-timeouts
feat(http): add timeouts and identifying user-agent to all provider HTTP calls
2 parents 1ed676e + 2769995 commit 0872734

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");
@@ -841,7 +841,7 @@ async fn generate_title_openai(
841841
model: &str,
842842
messages: &[Value],
843843
) -> AppResult<String> {
844-
let client = reqwest::Client::new();
844+
let client = http_client::request_client()?;
845845
let endpoint = format!(
846846
"{}/chat/completions",
847847
provider.base_url.trim_end_matches('/')
@@ -914,7 +914,7 @@ async fn generate_title_anthropic(
914914
format!("{}/messages", provider.base_url.trim_end_matches('/'))
915915
};
916916

917-
let client = reqwest::Client::new();
917+
let client = http_client::request_client()?;
918918
let mut request = client
919919
.post(&endpoint)
920920
.header(CONTENT_TYPE, "application/json")
@@ -1027,7 +1027,7 @@ pub async fn generate_excalidraw(
10271027

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

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

10331033
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
@@ -481,7 +481,7 @@ impl ToolExecutor {
481481
let query = input["query"]
482482
.as_str()
483483
.ok_or_else(|| AppError::Validation("Missing 'query' field".to_string()))?;
484-
let client = reqwest::Client::new();
484+
let client = crate::services::http_client::request_client()?;
485485
let url = format!(
486486
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
487487
urlencoding::encode(query)

0 commit comments

Comments
 (0)