From ff6c4128825c7b8fa7e7a7bdc0fbdad8ca5b2ee0 Mon Sep 17 00:00:00 2001 From: muava12 Date: Tue, 24 Feb 2026 13:34:49 +0800 Subject: [PATCH] fix(fallback): treat unclassified errors as retriable for fallback Change unclassified errors (e.g. connection reset, DNS failures) from aborting the fallback chain to triggering fallback with FailoverUnknown reason. This ensures user chat is processed by fallback models when the primary model encounters unexpected errors. Also send error notification to user via channel when all candidates are exhausted, so users see what went wrong. --- pkg/agent/loop.go | 8 ++++++ pkg/providers/fallback.go | 14 +++++----- pkg/providers/fallback_test.go | 48 ++++++++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5b1fc5e46..84a99d7f0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -428,6 +428,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 4. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { + // Show the error to user via channel before returning + if !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: fmt.Sprintf("⚠️ Error processing message: %s", err.Error()), + }) + } return "", err } diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index ac556e860..9962621bf 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -169,15 +169,15 @@ func (fc *FallbackChain) Execute( failErr := ClassifyError(err, candidate.Provider, candidate.Model) if failErr == nil { - // Unclassifiable error: do not fallback, return immediately. - result.Attempts = append(result.Attempts, FallbackAttempt{ + // Unclassifiable error: treat as retriable with "unknown" reason. + // This allows fallback to next candidate instead of aborting. + // Examples: connection reset, DNS failures, unexpected API responses. + failErr = &FailoverError{ + Reason: FailoverUnknown, Provider: candidate.Provider, Model: candidate.Model, - Error: err, - Duration: elapsed, - }) - return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", - candidate.Provider, candidate.Model, err) + Wrapped: err, + } } // Non-retriable error: abort immediately. diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1970ed49e..d924a147e 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -259,18 +259,56 @@ func TestFallback_UnclassifiedError(t *testing.T) { makeCandidate("anthropic", "claude"), } - attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { - attempt++ return nil, errors.New("completely unknown internal error") } _, err := fc.Execute(context.Background(), candidates, run) if err == nil { - t.Fatal("expected error for unclassified error") + t.Fatal("expected error when all candidates fail with unclassified error") } - if attempt != 1 { - t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt) + // Unclassified errors should now be treated as retriable (FailoverUnknown), + // so both candidates should be tried before exhaustion. + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Fatalf("expected FallbackExhaustedError, got %T: %v", err, err) + } + if len(exhausted.Attempts) != 2 { + t.Errorf("attempts = %d, want 2 (both candidates should be tried)", len(exhausted.Attempts)) + } +} + +func TestFallback_UnclassifiedError_FallbackSucceeds(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("sumo", "seed-mini"), + makeCandidate("groq", "llama-3"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + // Simulate unclassified error (e.g. connection reset) + return nil, errors.New("read tcp: connection reset by peer") + } + return &LLMResponse{Content: "fallback response", FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Provider != "groq" { + t.Errorf("provider = %q, want groq (fallback)", result.Provider) + } + if result.Response.Content != "fallback response" { + t.Errorf("content = %q, want 'fallback response'", result.Response.Content) + } + if len(result.Attempts) != 1 { + t.Errorf("attempts = %d, want 1 (failed first candidate recorded)", len(result.Attempts)) } }