Files
picoclaw/pkg/providers/error_classifier.go
T
Tong Niu d6e88da8ba fix(pkg):do regex precompile insteadd on the fly (#911)
* fix(pkg/providers):do regex precompile insteadd on the fly

* fix(providers): replace HTTP-specific regex with standalone status code matcher

The precompiled HTTP regex used uppercase "HTTP" which never matched
because ClassifyError lowercases the input. Replace it with a
case-insensitive word-boundary pattern that matches any standalone
3-digit status code (300-599), which also subsumes the HTTP/x.x case.

Add test case for standalone status code extraction.

* fix(providers): restore http regex and add standalone status code matcher

Restore the http-prefixed regex (without unnecessary (?i) flag since
input is already lowercased by ClassifyError) as a mid-priority pattern
to reduce false positives. Add a standalone word-boundary matcher as a
fallback for bare status codes like "429". Fix test to use lowercased
input matching the actual calling convention.

* perf(tools): move path regex compilation from per-call to package init

The path regex in guardCommand was compiled on every call. Hoist it
to a package-level var (absolutePathPattern) alongside defaultDenyPatterns
in a single var block, so it is compiled once at init time.

* style(tools): move inline comment to fix golines formatting error
2026-03-01 23:06:31 +11:00

254 lines
6.2 KiB
Go

package providers
import (
"context"
"regexp"
"strings"
)
// Common patterns in Go HTTP error messages
var httpStatusPatterns = []*regexp.Regexp{
regexp.MustCompile(`status[:\s]+(\d{3})`),
regexp.MustCompile(`http[/\s]+\d*\.?\d*\s+(\d{3})`),
regexp.MustCompile(`\b([3-5]\d{2})\b`),
}
// errorPattern defines a single pattern (string or regex) for error classification.
type errorPattern struct {
substring string
regex *regexp.Regexp
}
func substr(s string) errorPattern { return errorPattern{substring: s} }
func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} }
// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns).
var (
rateLimitPatterns = []errorPattern{
rxp(`rate[_ ]limit`),
substr("too many requests"),
substr("429"),
substr("exceeded your current quota"),
rxp(`exceeded.*quota`),
rxp(`resource has been exhausted`),
rxp(`resource.*exhausted`),
substr("resource_exhausted"),
substr("quota exceeded"),
substr("usage limit"),
}
overloadedPatterns = []errorPattern{
rxp(`overloaded_error`),
rxp(`"type"\s*:\s*"overloaded_error"`),
substr("overloaded"),
}
timeoutPatterns = []errorPattern{
substr("timeout"),
substr("timed out"),
substr("deadline exceeded"),
substr("context deadline exceeded"),
}
billingPatterns = []errorPattern{
rxp(`\b402\b`),
substr("payment required"),
substr("insufficient credits"),
substr("credit balance"),
substr("plans & billing"),
substr("insufficient balance"),
}
authPatterns = []errorPattern{
rxp(`invalid[_ ]?api[_ ]?key`),
substr("incorrect api key"),
substr("invalid token"),
substr("authentication"),
substr("re-authenticate"),
substr("oauth token refresh failed"),
substr("unauthorized"),
substr("forbidden"),
substr("access denied"),
substr("expired"),
substr("token has expired"),
rxp(`\b401\b`),
rxp(`\b403\b`),
substr("no credentials found"),
substr("no api key found"),
}
formatPatterns = []errorPattern{
substr("string should match pattern"),
substr("tool_use.id"),
substr("tool_use_id"),
substr("messages.1.content.1.tool_use.id"),
substr("invalid request format"),
}
imageDimensionPatterns = []errorPattern{
rxp(`image dimensions exceed max`),
}
imageSizePatterns = []errorPattern{
rxp(`image exceeds.*mb`),
}
// Transient HTTP status codes that map to timeout (server-side failures).
transientStatusCodes = map[int]bool{
500: true, 502: true, 503: true,
521: true, 522: true, 523: true, 524: true,
529: true,
}
)
// ClassifyError classifies an error into a FailoverError with reason.
// Returns nil if the error is not classifiable (unknown errors should not trigger fallback).
func ClassifyError(err error, provider, model string) *FailoverError {
if err == nil {
return nil
}
// Context cancellation: user abort, never fallback.
if err == context.Canceled {
return nil
}
// Context deadline exceeded: treat as timeout, always fallback.
if err == context.DeadlineExceeded {
return &FailoverError{
Reason: FailoverTimeout,
Provider: provider,
Model: model,
Wrapped: err,
}
}
msg := strings.ToLower(err.Error())
// Image dimension/size errors: non-retriable, non-fallback.
if IsImageDimensionError(msg) || IsImageSizeError(msg) {
return &FailoverError{
Reason: FailoverFormat,
Provider: provider,
Model: model,
Wrapped: err,
}
}
// Try HTTP status code extraction first.
if status := extractHTTPStatus(msg); status > 0 {
if reason := classifyByStatus(status); reason != "" {
return &FailoverError{
Reason: reason,
Provider: provider,
Model: model,
Status: status,
Wrapped: err,
}
}
}
// Message pattern matching (priority order from OpenClaw).
if reason := classifyByMessage(msg); reason != "" {
return &FailoverError{
Reason: reason,
Provider: provider,
Model: model,
Wrapped: err,
}
}
return nil
}
// classifyByStatus maps HTTP status codes to FailoverReason.
func classifyByStatus(status int) FailoverReason {
switch {
case status == 401 || status == 403:
return FailoverAuth
case status == 402:
return FailoverBilling
case status == 408:
return FailoverTimeout
case status == 429:
return FailoverRateLimit
case status == 400:
return FailoverFormat
case transientStatusCodes[status]:
return FailoverTimeout
}
return ""
}
// classifyByMessage matches error messages against patterns.
// Priority order matters (from OpenClaw classifyFailoverReason).
func classifyByMessage(msg string) FailoverReason {
if matchesAny(msg, rateLimitPatterns) {
return FailoverRateLimit
}
if matchesAny(msg, overloadedPatterns) {
return FailoverRateLimit // Overloaded treated as rate_limit
}
if matchesAny(msg, billingPatterns) {
return FailoverBilling
}
if matchesAny(msg, timeoutPatterns) {
return FailoverTimeout
}
if matchesAny(msg, authPatterns) {
return FailoverAuth
}
if matchesAny(msg, formatPatterns) {
return FailoverFormat
}
return ""
}
// extractHTTPStatus extracts an HTTP status code from an error message.
// Looks for patterns like "status: 429", "status 429", "http/1.1 429", "http 429", or standalone "429".
func extractHTTPStatus(msg string) int {
for _, p := range httpStatusPatterns {
if m := p.FindStringSubmatch(msg); len(m) > 1 {
return parseDigits(m[1])
}
}
return 0
}
// IsImageDimensionError returns true if the message indicates an image dimension error.
func IsImageDimensionError(msg string) bool {
return matchesAny(msg, imageDimensionPatterns)
}
// IsImageSizeError returns true if the message indicates an image file size error.
func IsImageSizeError(msg string) bool {
return matchesAny(msg, imageSizePatterns)
}
// matchesAny checks if msg matches any of the patterns.
func matchesAny(msg string, patterns []errorPattern) bool {
for _, p := range patterns {
if p.regex != nil {
if p.regex.MatchString(msg) {
return true
}
} else if p.substring != "" {
if strings.Contains(msg, p.substring) {
return true
}
}
}
return false
}
// parseDigits converts a string of digits to an int.
func parseDigits(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}