From 56ac14c0e548d49402590ea19373890218e144f2 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Fri, 20 Mar 2026 12:23:56 -0400 Subject: [PATCH] fix(providers): clear timeout error message and fallback on CLI provider timeout When a CLI provider subprocess was killed due to request_timeout, the error reported was the raw subprocess signal ("signal: killed"), giving the user no indication that a timeout was the cause. Additionally, the fallback chain would not trigger because ClassifyError used == to match context.DeadlineExceeded, which does not match wrapped errors. - Each CLI provider now checks ctx.Err() == context.DeadlineExceeded after cmd.Run() fails and returns a descriptive error (e.g. "claude cli timed out after 30s") that wraps context.DeadlineExceeded - ClassifyError updated to use errors.Is instead of == when checking for context.DeadlineExceeded, so wrapped timeout errors correctly classify as FailoverTimeout and trigger fallback to the next candidate in the chain Co-Authored-By: Claude Sonnet 4.6 --- pkg/providers/claude_cli_provider.go | 3 +++ pkg/providers/codex_cli_provider.go | 3 +++ pkg/providers/error_classifier.go | 3 ++- pkg/providers/gemini_cli_provider.go | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index f63b6f00d..1183f5618 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -63,6 +63,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("claude cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch { diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 5cfca37f8..7d347aa54 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -86,6 +86,9 @@ func (p *CodexCliProvider) Chat( } if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("codex cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } if ctx.Err() == context.Canceled { return nil, ctx.Err() } diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..46d3b4c82 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -2,6 +2,7 @@ package providers import ( "context" + "errors" "regexp" "strings" ) @@ -114,7 +115,7 @@ func ClassifyError(err error, provider, model string) *FailoverError { } // Context deadline exceeded: treat as timeout, always fallback. - if err == context.DeadlineExceeded { + if errors.Is(err, context.DeadlineExceeded) { return &FailoverError{ Reason: FailoverTimeout, Provider: provider, diff --git a/pkg/providers/gemini_cli_provider.go b/pkg/providers/gemini_cli_provider.go index 2c644f378..1a974754f 100644 --- a/pkg/providers/gemini_cli_provider.go +++ b/pkg/providers/gemini_cli_provider.go @@ -63,6 +63,9 @@ func (p *GeminiCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("gemini cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch {