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.
This commit is contained in:
muava12 2026-02-24 13:34:49 +08:00
parent ef7cdde43c
commit ff6c412882
3 changed files with 58 additions and 12 deletions

View file

@ -428,6 +428,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 4. Run LLM iteration loop // 4. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil { 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 return "", err
} }

View file

@ -169,15 +169,15 @@ func (fc *FallbackChain) Execute(
failErr := ClassifyError(err, candidate.Provider, candidate.Model) failErr := ClassifyError(err, candidate.Provider, candidate.Model)
if failErr == nil { if failErr == nil {
// Unclassifiable error: do not fallback, return immediately. // Unclassifiable error: treat as retriable with "unknown" reason.
result.Attempts = append(result.Attempts, FallbackAttempt{ // This allows fallback to next candidate instead of aborting.
// Examples: connection reset, DNS failures, unexpected API responses.
failErr = &FailoverError{
Reason: FailoverUnknown,
Provider: candidate.Provider, Provider: candidate.Provider,
Model: candidate.Model, Model: candidate.Model,
Error: err, Wrapped: err,
Duration: elapsed, }
})
return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
candidate.Provider, candidate.Model, err)
} }
// Non-retriable error: abort immediately. // Non-retriable error: abort immediately.

View file

@ -259,18 +259,56 @@ func TestFallback_UnclassifiedError(t *testing.T) {
makeCandidate("anthropic", "claude"), makeCandidate("anthropic", "claude"),
} }
attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
attempt++
return nil, errors.New("completely unknown internal error") return nil, errors.New("completely unknown internal error")
} }
_, err := fc.Execute(context.Background(), candidates, run) _, err := fc.Execute(context.Background(), candidates, run)
if err == nil { if err == nil {
t.Fatal("expected error for unclassified error") t.Fatal("expected error when all candidates fail with unclassified error")
} }
if attempt != 1 { // Unclassified errors should now be treated as retriable (FailoverUnknown),
t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt) // 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))
} }
} }