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 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-20 12:23:56 -04:00
parent df967fe63c
commit 56ac14c0e5
4 changed files with 11 additions and 1 deletions

View file

@ -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 {

View file

@ -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()
}

View file

@ -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,

View file

@ -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 {