From dba48f866e6fc328d214eff71fd50e67735124d6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 12:43:48 -0500 Subject: [PATCH] Fix CodergenHandler swallowing retryable errors, preventing engine retries The handler was converting all backend errors into Ok(Outcome::fail(...)), which the engine treats as a terminal result. Retryable errors (timeouts, network failures) are now propagated as Err so the engine retry loop kicks in. Co-Authored-By: Claude Opus 4.6 --- crates/attractor/src/handler/codergen.rs | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/attractor/src/handler/codergen.rs b/crates/attractor/src/handler/codergen.rs index 8300620c9..9391bce09 100644 --- a/crates/attractor/src/handler/codergen.rs +++ b/crates/attractor/src/handler/codergen.rs @@ -118,6 +118,9 @@ impl Handler for CodergenHandler { return Ok(outcome); } Ok(CodergenResult::Text(text)) => text, + Err(e) if e.is_retryable() => { + return Err(e); + } Err(e) => { return Ok(Outcome::fail(e.to_string())); } @@ -475,4 +478,64 @@ mod tests { let result = captured.lock().unwrap().clone(); assert_eq!(result, Some(None)); } + + #[tokio::test] + async fn codergen_handler_propagates_retryable_backend_error() { + struct FailingBackend; + + #[async_trait] + impl CodergenBackend for FailingBackend { + async fn run( + &self, + _node: &Node, + _prompt: &str, + _context: &Context, + _thread_id: Option<&str>, + ) -> Result { + Err(AttractorError::Handler("Request timed out".to_string())) + } + } + + let handler = CodergenHandler::new(Some(Box::new(FailingBackend))); + let node = Node::new("step"); + let context = Context::new(); + let graph = Graph::new("test"); + let tmp = TempDir::new().unwrap(); + + let result = handler.execute(&node, &context, &graph, tmp.path()).await; + let err = result.unwrap_err(); + assert!(err.is_retryable()); + assert!(err.to_string().contains("Request timed out")); + } + + #[tokio::test] + async fn codergen_handler_returns_fail_outcome_for_non_retryable_backend_error() { + struct ValidationFailBackend; + + #[async_trait] + impl CodergenBackend for ValidationFailBackend { + async fn run( + &self, + _node: &Node, + _prompt: &str, + _context: &Context, + _thread_id: Option<&str>, + ) -> Result { + Err(AttractorError::Validation("bad config".to_string())) + } + } + + let handler = CodergenHandler::new(Some(Box::new(ValidationFailBackend))); + let node = Node::new("step"); + let context = Context::new(); + let graph = Graph::new("test"); + let tmp = TempDir::new().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, tmp.path()) + .await + .unwrap(); + assert_eq!(outcome.status, crate::outcome::StageStatus::Fail); + assert!(outcome.failure_reason.unwrap().contains("bad config")); + } }