fix(provider): correct model_not_found misclassification and unify retry error handling
- error_classifier: add modelNotFoundPatterns with highest priority in classifyByMessage(); for transient HTTP statuses (5xx), override with message-level classification when body indicates a concrete non-transient error (e.g. zhipu 503 + model_not_found → FailoverFormat, not timeout) - loop: replace ad-hoc string matching in retry loop with errors.As() to extract typed FailoverReason from FallbackChain, preserving string matching as backward-compat fallback for single-candidate path - fallback: add Unwrap() to FallbackExhaustedError so errors.As() can traverse into the underlying FailoverError
This commit is contained in:
parent
1809d04905
commit
dc109b6816
3 changed files with 79 additions and 16 deletions
|
|
@ -1941,23 +1941,41 @@ turnLoop:
|
|||
return al.abortTurn(ts)
|
||||
}
|
||||
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
||||
strings.Contains(errMsg, "deadline exceeded") ||
|
||||
strings.Contains(errMsg, "client.timeout") ||
|
||||
strings.Contains(errMsg, "timed out") ||
|
||||
strings.Contains(errMsg, "timeout exceeded")
|
||||
// Extract typed FailoverReason from the error chain.
|
||||
// FallbackChain already classifies errors; the outer retry loop
|
||||
// only handles specific recoverable scenarios.
|
||||
var failErr *providers.FailoverError
|
||||
failReason := providers.FailoverUnknown
|
||||
if errors.As(err, &failErr) {
|
||||
failReason = failErr.Reason
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
failReason = providers.FailoverTimeout
|
||||
} else {
|
||||
// Single-candidate path: classify raw error via ClassifyError
|
||||
// to maintain backward compatibility with provider error messages.
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if strings.Contains(errMsg, "deadline exceeded") ||
|
||||
strings.Contains(errMsg, "client.timeout") ||
|
||||
strings.Contains(errMsg, "timed out") ||
|
||||
strings.Contains(errMsg, "timeout exceeded") {
|
||||
failReason = providers.FailoverTimeout
|
||||
} else if strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
strings.Contains(errMsg, "context window") ||
|
||||
strings.Contains(errMsg, "context_window") ||
|
||||
strings.Contains(errMsg, "maximum context length") ||
|
||||
strings.Contains(errMsg, "token limit") ||
|
||||
strings.Contains(errMsg, "too many tokens") ||
|
||||
strings.Contains(errMsg, "max_tokens") ||
|
||||
strings.Contains(errMsg, "invalidparameter") ||
|
||||
strings.Contains(errMsg, "prompt is too long") ||
|
||||
strings.Contains(errMsg, "request too large") {
|
||||
failReason = providers.FailoverContextOverflow
|
||||
}
|
||||
}
|
||||
|
||||
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
strings.Contains(errMsg, "context window") ||
|
||||
strings.Contains(errMsg, "context_window") ||
|
||||
strings.Contains(errMsg, "maximum context length") ||
|
||||
strings.Contains(errMsg, "token limit") ||
|
||||
strings.Contains(errMsg, "too many tokens") ||
|
||||
strings.Contains(errMsg, "max_tokens") ||
|
||||
strings.Contains(errMsg, "invalidparameter") ||
|
||||
strings.Contains(errMsg, "prompt is too long") ||
|
||||
strings.Contains(errMsg, "request too large"))
|
||||
isTimeoutError := failReason == providers.FailoverTimeout
|
||||
|
||||
isContextError := failReason == providers.FailoverContextOverflow
|
||||
|
||||
if isTimeoutError && retry < maxRetries {
|
||||
backoff := time.Duration(retry+1) * 5 * time.Second
|
||||
|
|
|
|||
|
|
@ -102,6 +102,16 @@ var (
|
|||
rxp(`image exceeds.*mb`),
|
||||
}
|
||||
|
||||
modelNotFoundPatterns = []errorPattern{
|
||||
rxp(`model[_ ]?not[_ ]?found`),
|
||||
rxp(`does not exist.*model`),
|
||||
rxp(`model.*does not exist`),
|
||||
rxp(`invalid model`),
|
||||
rxp(`model.*not available`),
|
||||
rxp(`model.*not supported`),
|
||||
rxp(`unknown model`),
|
||||
}
|
||||
|
||||
// Transient HTTP status codes that map to timeout (server-side failures).
|
||||
transientStatusCodes = map[int]bool{
|
||||
500: true, 502: true, 503: true,
|
||||
|
|
@ -147,6 +157,21 @@ func ClassifyError(err error, provider, model string) *FailoverError {
|
|||
// Try HTTP status code extraction first.
|
||||
if status := extractHTTPStatus(msg); status > 0 {
|
||||
if reason := classifyByStatus(status); reason != "" {
|
||||
// For transient status codes (5xx), the message body may contain a
|
||||
// more specific, non-transient error (e.g. zhipu returns 503 with
|
||||
// "model_not_found"). Check message patterns and prefer them when
|
||||
// they indicate a concrete, non-transient failure.
|
||||
if isTransientStatus(status) {
|
||||
if msgReason := classifyByMessage(msg); msgReason != "" && msgReason != FailoverTimeout {
|
||||
return &FailoverError{
|
||||
Reason: msgReason,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Status: status,
|
||||
Wrapped: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
return &FailoverError{
|
||||
Reason: reason,
|
||||
Provider: provider,
|
||||
|
|
@ -192,6 +217,9 @@ func classifyByStatus(status int) FailoverReason {
|
|||
// classifyByMessage matches error messages against patterns.
|
||||
// Priority order matters (from OpenClaw classifyFailoverReason).
|
||||
func classifyByMessage(msg string) FailoverReason {
|
||||
if matchesAny(msg, modelNotFoundPatterns) {
|
||||
return FailoverFormat // model_not_found is a configuration error, not retriable
|
||||
}
|
||||
if matchesAny(msg, rateLimitPatterns) {
|
||||
return FailoverRateLimit
|
||||
}
|
||||
|
|
@ -263,3 +291,9 @@ func parseDigits(s string) int {
|
|||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isTransientStatus returns true for 5xx status codes that represent
|
||||
// server-side transient failures (should be retried via fallback).
|
||||
func isTransientStatus(status int) bool {
|
||||
return transientStatusCodes[status]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,3 +302,14 @@ func (e *FallbackExhaustedError) Error() string {
|
|||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Unwrap returns the last non-skipped attempt's error for errors.Is/As traversal.
|
||||
// This allows errors.As(err, &FailoverError{}) to work through FallbackExhaustedError.
|
||||
func (e *FallbackExhaustedError) Unwrap() error {
|
||||
for i := len(e.Attempts) - 1; i >= 0; i-- {
|
||||
if !e.Attempts[i].Skipped && e.Attempts[i].Error != nil {
|
||||
return e.Attempts[i].Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue