|
| 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 | +} |
0 commit comments