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:
kent.liu 2026-03-28 23:35:06 +08:00
parent 1809d04905
commit dc109b6816
3 changed files with 79 additions and 16 deletions

View file

@ -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

View file

@ -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]
}

View file

@ -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
}