Merge pull request #22 from hobbyistlabs-coder/fix/granular-error-classification-12017654499930892984

🧹 Refactor context length error handling to use providers error classification
This commit is contained in:
hobbyistlabs-coder 2026-03-13 18:32:54 -04:00 committed by GitHub
commit 30ddae62c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 72 additions and 28 deletions

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"jane/pkg/bus" "jane/pkg/bus"
@ -77,25 +76,27 @@ func (al *AgentLoop) executeLLMWithRetry(
break break
} }
errMsg := strings.ToLower(err.Error()) isTimeoutError := false
isContextError := false
// Check if this is a network/HTTP timeout — not a context window error. var failErr *providers.FailoverError
isTimeoutError := errors.Is(err, context.DeadlineExceeded) || if errors.As(err, &failErr) {
strings.Contains(errMsg, "deadline exceeded") || if failErr.Reason == providers.FailoverTimeout {
strings.Contains(errMsg, "client.timeout") || isTimeoutError = true
strings.Contains(errMsg, "timed out") || } else if failErr.Reason == providers.FailoverContextLength {
strings.Contains(errMsg, "timeout exceeded") isContextError = true
}
// Detect real context window / token limit errors, excluding network timeouts. } else {
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || // If not a fallback error, check directly using ClassifyError
strings.Contains(errMsg, "context window") || // The provider might not be wrapped if no fallback chain is active
strings.Contains(errMsg, "maximum context length") || if directFailErr := providers.ClassifyError(err, "", ""); directFailErr != nil {
strings.Contains(errMsg, "token limit") || if directFailErr.Reason == providers.FailoverTimeout {
strings.Contains(errMsg, "too many tokens") || isTimeoutError = true
strings.Contains(errMsg, "max_tokens") || } else if directFailErr.Reason == providers.FailoverContextLength {
strings.Contains(errMsg, "invalidparameter") || isContextError = true
strings.Contains(errMsg, "prompt is too long") || }
strings.Contains(errMsg, "request too large")) }
}
if isTimeoutError && retry < maxRetries { if isTimeoutError && retry < maxRetries {
// Exponential backoff: 2s, 4s, 8s // Exponential backoff: 2s, 4s, 8s

View file

@ -85,6 +85,18 @@ var (
substr("invalid request format"), substr("invalid request format"),
} }
contextLengthPatterns = []errorPattern{
substr("context_length_exceeded"),
substr("context window"),
substr("maximum context length"),
substr("token limit"),
substr("too many tokens"),
substr("max_tokens"),
substr("invalidparameter"),
substr("prompt is too long"),
substr("request too large"),
}
imageDimensionPatterns = []errorPattern{ imageDimensionPatterns = []errorPattern{
rxp(`image dimensions exceed max`), rxp(`image dimensions exceed max`),
} }
@ -201,6 +213,9 @@ func classifyByMessage(msg string) FailoverReason {
if matchesAny(msg, formatPatterns) { if matchesAny(msg, formatPatterns) {
return FailoverFormat return FailoverFormat
} }
if matchesAny(msg, contextLengthPatterns) {
return FailoverContextLength
}
return "" return ""
} }

View file

@ -207,6 +207,32 @@ func TestClassifyError_FormatPatterns(t *testing.T) {
} }
} }
func TestClassifyError_ContextLengthPatterns(t *testing.T) {
patterns := []string{
"context_length_exceeded",
"context window reached",
"maximum context length exceeded",
"token limit reached",
"too many tokens for this model",
"max_tokens limit hit",
"invalidparameter",
"prompt is too long",
"request too large",
}
for _, msg := range patterns {
err := errors.New(msg)
result := ClassifyError(err, "anthropic", "claude")
if result == nil {
t.Errorf("pattern %q: expected non-nil", msg)
continue
}
if result.Reason != FailoverContextLength {
t.Errorf("pattern %q: reason = %q, want context_length", msg, result.Reason)
}
}
}
func TestClassifyError_ImageDimensionError(t *testing.T) { func TestClassifyError_ImageDimensionError(t *testing.T) {
err := errors.New("image dimensions exceed max allowed 2048x2048") err := errors.New("image dimensions exceed max allowed 2048x2048")
result := ClassifyError(err, "openai", "gpt-4o") result := ClassifyError(err, "openai", "gpt-4o")
@ -265,6 +291,7 @@ func TestFailoverError_IsRetriable(t *testing.T) {
{FailoverTimeout, true}, {FailoverTimeout, true},
{FailoverOverloaded, true}, {FailoverOverloaded, true},
{FailoverFormat, false}, {FailoverFormat, false},
{FailoverContextLength, false},
{FailoverUnknown, true}, {FailoverUnknown, true},
} }

View file

@ -53,6 +53,7 @@ const (
FailoverBilling FailoverReason = "billing" FailoverBilling FailoverReason = "billing"
FailoverTimeout FailoverReason = "timeout" FailoverTimeout FailoverReason = "timeout"
FailoverFormat FailoverReason = "format" FailoverFormat FailoverReason = "format"
FailoverContextLength FailoverReason = "context_length"
FailoverOverloaded FailoverReason = "overloaded" FailoverOverloaded FailoverReason = "overloaded"
FailoverUnknown FailoverReason = "unknown" FailoverUnknown FailoverReason = "unknown"
) )
@ -76,9 +77,9 @@ func (e *FailoverError) Unwrap() error {
} }
// IsRetriable returns true if this error should trigger fallback to next candidate. // IsRetriable returns true if this error should trigger fallback to next candidate.
// Non-retriable: Format errors (bad request structure, image dimension/size). // Non-retriable: Format errors (bad request structure, image dimension/size), Context length exceeded.
func (e *FailoverError) IsRetriable() bool { func (e *FailoverError) IsRetriable() bool {
return e.Reason != FailoverFormat return e.Reason != FailoverFormat && e.Reason != FailoverContextLength
} }
// ModelConfig holds primary model and fallback list. // ModelConfig holds primary model and fallback list.