From 3fe667591d25b3b1144875536e9223c60aaecb67 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Mon, 23 Mar 2026 11:41:57 +0100 Subject: [PATCH] fix: LLM providers use own HTTP client with 120s timeout (was sharing scraper's 15s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scraper client (build_scraper_client) has a 15s timeout appropriate for web scraping, but LLM API calls — especially with web search — take 30-60s. LLM providers now build their own reqwest client with 120s timeout via build_llm_client(). Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/handlers/api_keys.rs | 2 +- backend/src/services/llm/factory.rs | 46 ++++++++++++++++++----------- backend/src/services/synthesis.rs | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/backend/src/handlers/api_keys.rs b/backend/src/handlers/api_keys.rs index 90e53ae..57412c5 100644 --- a/backend/src/handlers/api_keys.rs +++ b/backend/src/handlers/api_keys.rs @@ -122,7 +122,7 @@ pub async fn test_key( encryption::decrypt(&master_key, &stored_key.encrypted_key, &stored_key.nonce)?; // Create a provider instance - let llm_provider = factory::create_provider(&provider, decrypted_key, &state.http_client)?; + let llm_provider = factory::create_provider(&provider, decrypted_key)?; // Make a minimal test call using the rewrite pass (no web search needed) let test_schema = serde_json::json!({ diff --git a/backend/src/services/llm/factory.rs b/backend/src/services/llm/factory.rs index 9e985be..f9f2316 100644 --- a/backend/src/services/llm/factory.rs +++ b/backend/src/services/llm/factory.rs @@ -7,27 +7,42 @@ use super::openai::OpenAiProvider; use super::LlmProvider; use crate::errors::AppError; +/// LLM API timeout: 120 seconds. +/// LLM calls with web search can take 30-60s; 120s provides headroom. +const LLM_TIMEOUT_SECS: u64 = 120; + +/// Build an HTTP client configured for LLM API calls. +/// +/// Uses a longer timeout than the scraper client since LLM calls +/// (especially with web search) are significantly slower. +fn build_llm_client() -> Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(LLM_TIMEOUT_SECS)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build LLM HTTP client: {}", e))) +} + /// Create an LLM provider instance for the given provider name. /// +/// Each provider gets its own HTTP client with a 120s timeout, +/// separate from the scraper client (15s timeout). +/// /// # Arguments /// * `provider_name` — One of "gemini", "openai", "anthropic" /// * `api_key` — The decrypted API key for the provider -/// * `http_client` — Shared HTTP client for making API calls /// /// # Errors /// Returns `AppError::BadRequest` if the provider name is unknown. pub fn create_provider( provider_name: &str, api_key: String, - http_client: &reqwest::Client, ) -> Result, AppError> { + let http_client = build_llm_client()?; match provider_name { - "gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))), - "openai" => Ok(Box::new(OpenAiProvider::new(api_key, http_client.clone()))), - "anthropic" => Ok(Box::new(AnthropicProvider::new( - api_key, - http_client.clone(), - ))), + "gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client))), + "openai" => Ok(Box::new(OpenAiProvider::new(api_key, http_client))), + "anthropic" => Ok(Box::new(AnthropicProvider::new(api_key, http_client))), _ => Err(AppError::BadRequest(format!( "Unknown provider: '{}'", provider_name @@ -41,32 +56,28 @@ mod tests { #[test] fn factory_creates_gemini_provider() { - let client = reqwest::Client::new(); - let provider = create_provider("gemini", "test-key".into(), &client).unwrap(); + let provider = create_provider("gemini", "test-key".into()).unwrap(); assert_eq!(provider.provider_id(), "gemini"); assert!(provider.supports_web_search()); } #[test] fn factory_creates_openai_provider() { - let client = reqwest::Client::new(); - let provider = create_provider("openai", "test-key".into(), &client).unwrap(); + let provider = create_provider("openai", "test-key".into()).unwrap(); assert_eq!(provider.provider_id(), "openai"); assert!(provider.supports_web_search()); } #[test] fn factory_creates_anthropic_provider() { - let client = reqwest::Client::new(); - let provider = create_provider("anthropic", "test-key".into(), &client).unwrap(); + let provider = create_provider("anthropic", "test-key".into()).unwrap(); assert_eq!(provider.provider_id(), "anthropic"); assert!(provider.supports_web_search()); } #[test] fn factory_rejects_unknown_provider() { - let client = reqwest::Client::new(); - let result = create_provider("mistral", "test-key".into(), &client); + let result = create_provider("mistral", "test-key".into()); match result { Err(AppError::BadRequest(msg)) => assert!(msg.contains("Unknown provider")), Err(_) => panic!("Expected BadRequest variant"), @@ -76,8 +87,7 @@ mod tests { #[test] fn factory_rejects_empty_provider() { - let client = reqwest::Client::new(); - let result = create_provider("", "test-key".into(), &client); + let result = create_provider("", "test-key".into()); match result { Err(AppError::BadRequest(msg)) => assert!(msg.contains("Unknown provider")), Err(_) => panic!("Expected BadRequest variant"), diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index 8595574..c38b41d 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -272,7 +272,7 @@ async fn run_generation_inner( emit_progress(tx, "provider", "Configuration du fournisseur IA...", 15); let (provider_name, api_key) = resolve_provider_and_key(state, user_id, &settings).await?; - let provider = create_provider(&provider_name, api_key, &state.http_client)?; + let provider = create_provider(&provider_name, api_key)?; // Step 4: Build schema from categories let schema = build_category_schema(&settings.categories);