diff --git a/src/llm.rs b/src/llm.rs index 518820e..5fabc3d 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -413,7 +413,16 @@ impl LlmClient { } Err(mut e) => { let elapsed = elapsed_text(started_at.elapsed()); - if index + 1 < chain.len() { + let has_fallback = index + 1 < chain.len(); + if e.is_timeout() && has_fallback { + eprintln!( + "postil: scorer {model_log} timed out after {elapsed}, falling back to next scorer" + ); + } else if e.is_timeout() { + eprintln!( + "postil: scorer {model_log} timed out after {elapsed}; no fallback scorers remain" + ); + } else if has_fallback { eprintln!( "postil: scorer {model_log} failed after {elapsed}, falling back to next scorer: {}", log_text(&format!("{e:#}")) diff --git a/tests/e2e.rs b/tests/e2e.rs index 8efcd0e..bccc861 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -318,6 +318,60 @@ async fn scorer_lowers_confidence_and_stores_both_values() { assert!(scorer_user.contains("diffHunk")); } +#[tokio::test] +async fn slow_scorer_request_times_out_and_falls_back() { + let server = MockServer::start().await; + mock_review_model( + &server, + "generator-model", + json!([finding_at(41, "warn", 0.92)]), + ) + .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("anthropic/claude-haiku-4.5")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(std::time::Duration::from_millis(1500)) + .set_body_json(scorer_content(json!([]))), + ) + .mount(&server) + .await; + mock_scorer_model( + &server, + "openai/gpt-5-mini", + json!([{ + "index": 0, + "confidence": 0.82, + "kind": "risk", + "reason": "finding is well grounded" + }]), + ) + .await; + + let dir = tempfile::tempdir().unwrap(); + let diff = write_diff(dir.path()); + let out = postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("POSTIL_LLM_REQUEST_TIMEOUT_SECS", "1") + .env("REVIEW_MODEL", "generator-model") + .args(["review", "--diff-file"]) + .arg(&diff) + .args(["--output", "json"]) + .assert() + .code(0); + + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + let env: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(env["scorerModel"], "openai/gpt-5-mini"); + assert!(stderr.contains("postil: scorer anthropic/claude-haiku-4.5 timed out after")); + assert!(stderr.contains("falling back to next scorer")); + assert!(stderr.contains("postil: running scorer with openai/gpt-5-mini")); + assert!(stderr.contains("postil: scorer openai/gpt-5-mini completed successfully in")); +} + #[tokio::test] async fn scorer_kind_escalation_into_configured_blocking_kind_takes_effect() { let server = MockServer::start().await;