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:
parent
ef7cdde43c
commit
ff6c412882
3 changed files with 58 additions and 12 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue