From a9b60648ee310d0c0d2a03b04099683ec4b362c5 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Wed, 8 Apr 2026 18:44:56 +0200 Subject: [PATCH] feat: make generation timeout configurable via GENERATION_TIMEOUT_MINUTES The hardcoded 15-minute timeout was too short for some syntheses. Now configurable via env var with a default of 30 minutes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 +++++ backend/src/config.rs | 16 ++++++++++++++++ backend/src/handlers/generation.rs | 8 +++++--- backend/src/services/scheduler.rs | 5 +++-- backend/tests/common/mod.rs | 1 + 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index a52af0b..3e8e881 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,8 @@ EMAIL_FROM=AI Weekly Synth # Sign up at https://dash.cloudflare.com/turnstile TURNSTILE_SECRET_KEY=0x4AAAAAAA_CHANGE_ME TURNSTILE_SITE_KEY=0x4BBBBBB_CHANGE_ME + +# --- Generation --- +# Maximum time (in minutes) allowed for a synthesis generation before timeout. +# Default: 30 +# GENERATION_TIMEOUT_MINUTES=30 diff --git a/backend/src/config.rs b/backend/src/config.rs index 2ee1a25..e1339c7 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -27,6 +27,9 @@ pub struct AppConfig { // Captcha (Cloudflare Turnstile) pub turnstile_secret_key: String, pub turnstile_site_key: String, + + // Generation + pub generation_timeout_secs: u64, } impl AppConfig { @@ -51,6 +54,12 @@ impl AppConfig { let static_dir = env::var("STATIC_DIR") .unwrap_or_else(|_| "../frontend/dist".to_string()); + let generation_timeout_secs = env::var("GENERATION_TIMEOUT_MINUTES") + .unwrap_or_else(|_| "30".to_string()) + .parse::() + .map_err(|_| "GENERATION_TIMEOUT_MINUTES must be a valid positive integer".to_string())? + * 60; + Ok(Self { database_url, master_encryption_key: Arc::new(master_encryption_key), @@ -61,6 +70,7 @@ impl AppConfig { email_from, turnstile_secret_key, turnstile_site_key, + generation_timeout_secs, }) } @@ -115,6 +125,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; assert!(config.validate().is_ok()); } @@ -131,6 +142,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("MASTER_ENCRYPTION_KEY")); @@ -148,6 +160,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("MASTER_ENCRYPTION_KEY")); @@ -165,6 +178,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("APP_URL")); @@ -182,6 +196,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("trailing slash")); @@ -199,6 +214,7 @@ mod tests { email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), + generation_timeout_secs: 1800, }; assert!(config.is_secure()); diff --git a/backend/src/handlers/generation.rs b/backend/src/handlers/generation.rs index dd5fe9f..3e034ed 100644 --- a/backend/src/handlers/generation.rs +++ b/backend/src/handlers/generation.rs @@ -86,13 +86,15 @@ pub async fn trigger_generate( let state_for_panic = state.clone(); let join_handle = tokio::spawn(async move { - let timeout_duration = std::time::Duration::from_secs(900); + let timeout_secs = state_clone.config.generation_timeout_secs; + let timeout_duration = std::time::Duration::from_secs(timeout_secs); match tokio::time::timeout(timeout_duration, synthesis::run_generation(job_id, state_clone.clone(), user_id, theme_id, tx.clone(), None, cancelled)).await { Ok(()) => {} Err(_) => { - tracing::error!(job_id = %job_id, user_id = %user_id, "Generation timed out after 15 minutes"); + let timeout_mins = timeout_secs / 60; + tracing::error!(job_id = %job_id, user_id = %user_id, timeout_mins, "Generation timed out"); let _ = tx.send(ProgressEvent::Error { - message: "La generation a depasse le delai maximum de 15 minutes.".into(), + message: format!("La generation a depasse le delai maximum de {} minutes.", timeout_mins), }); } } diff --git a/backend/src/services/scheduler.rs b/backend/src/services/scheduler.rs index a6c552c..22b1655 100644 --- a/backend/src/services/scheduler.rs +++ b/backend/src/services/scheduler.rs @@ -54,8 +54,9 @@ pub async fn run_scheduled_jobs(state: &AppState) { let job_id = Uuid::new_v4(); let cancelled = AtomicBool::new(false); + let timeout_secs = state.config.generation_timeout_secs; let timeout_result = tokio::time::timeout( - std::time::Duration::from_secs(900), + std::time::Duration::from_secs(timeout_secs), synthesis::run_generation_inner( job_id, state, schedule.user_id, schedule.theme_id, &tx, None, &cancelled, @@ -65,7 +66,7 @@ pub async fn run_scheduled_jobs(state: &AppState) { let result = match timeout_result { Ok(inner) => inner, Err(_) => { - tracing::error!(schedule_id = %schedule.id, "Scheduled generation timed out after 15 minutes"); + tracing::error!(schedule_id = %schedule.id, timeout_mins = timeout_secs / 60, "Scheduled generation timed out"); continue; } }; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index a4c7a2a..9c06cd7 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -115,6 +115,7 @@ impl TestApp { email_from: "test@example.com".into(), turnstile_secret_key: TURNSTILE_TEST_KEY.into(), turnstile_site_key: "test-site-key".into(), + generation_timeout_secs: 1800, }; let http_client = reqwest::Client::new();