Files
sub2api/backend/internal/service/kiro_error_classifier.go
2026-04-30 14:02:05 +08:00

223 lines
9.0 KiB
Go

package service
import (
"errors"
"net"
"net/http"
"strings"
"github.com/tidwall/gjson"
)
const (
kiroErrorAuthError = "auth_error"
kiroErrorMonthlyRequest = "monthly_request_count"
kiroErrorProfileError = "profile_error"
kiroErrorQuotaExhausted = "quota_exhausted"
kiroErrorOverageExhausted = "overage_exhausted"
kiroErrorRateLimited = "rate_limited"
kiroErrorSuspended = "suspended"
kiroErrorUsageForbidden = "usage_forbidden"
kiroErrorUpstreamTransient = "upstream_transient"
kiroErrorBadRequestSchema = "bad_request_schema"
kiroErrorBadRequestToolPairing = "bad_request_tool_pairing"
kiroErrorBadRequestInvalidModel = "bad_request_invalid_model"
kiroErrorBadRequestAuth = "bad_request_auth"
kiroErrorBadRequestQuota = "bad_request_quota"
kiroErrorBadRequestUnknown = "bad_request_unknown"
kiroErrorRefreshTokenInvalid = "refresh_token_invalid"
kiroQuotaStateNormal = "normal"
kiroQuotaStateOverageActive = "overage_active"
kiroQuotaStateCreditsExhausted = "credits_exhausted"
kiroQuotaStateOverageExhausted = "overage_exhausted"
)
type kiroErrorClassification struct {
Category string
StatusCode int
Message string
}
func classifyKiroHTTPError(statusCode int, body string) kiroErrorClassification {
trimmed := strings.TrimSpace(body)
lower := strings.ToLower(trimmed)
switch {
case statusCode == http.StatusUnauthorized:
return kiroErrorClassification{Category: kiroErrorAuthError, StatusCode: statusCode, Message: trimmed}
case statusCode == http.StatusPaymentRequired && looksLikeKiroMonthlyRequestCountError(trimmed):
return kiroErrorClassification{Category: kiroErrorMonthlyRequest, StatusCode: statusCode, Message: trimmed}
case statusCode == http.StatusForbidden && isKiroSuspendedBody([]byte(trimmed)):
return kiroErrorClassification{Category: kiroErrorSuspended, StatusCode: statusCode, Message: trimmed}
case looksLikeKiroProfileError(lower):
return kiroErrorClassification{Category: kiroErrorProfileError, StatusCode: statusCode, Message: trimmed}
case statusCode == http.StatusBadRequest:
return classifyKiroBadRequest(trimmed, lower)
case statusCode == http.StatusForbidden && isKiroTokenErrorBody([]byte(trimmed)):
return kiroErrorClassification{Category: kiroErrorAuthError, StatusCode: statusCode, Message: trimmed}
case looksLikeKiroOverageExhaustedError(lower):
return kiroErrorClassification{Category: kiroErrorOverageExhausted, StatusCode: statusCode, Message: trimmed}
case looksLikeKiroQuotaExhaustedError(lower):
return kiroErrorClassification{Category: kiroErrorQuotaExhausted, StatusCode: statusCode, Message: trimmed}
case statusCode == http.StatusTooManyRequests:
return kiroErrorClassification{Category: kiroErrorRateLimited, StatusCode: statusCode, Message: trimmed}
case statusCode == http.StatusForbidden:
return kiroErrorClassification{Category: kiroErrorUsageForbidden, StatusCode: statusCode, Message: trimmed}
case statusCode >= 500:
return kiroErrorClassification{Category: kiroErrorUpstreamTransient, StatusCode: statusCode, Message: trimmed}
default:
return kiroErrorClassification{Category: kiroErrorUpstreamTransient, StatusCode: statusCode, Message: trimmed}
}
}
func classifyKiroError(err error) kiroErrorClassification {
if err == nil {
return kiroErrorClassification{}
}
var httpErr *kiroUsageHTTPError
if errors.As(err, &httpErr) && httpErr != nil {
return classifyKiroHTTPError(httpErr.StatusCode, httpErr.Body)
}
errStr := strings.TrimSpace(err.Error())
lower := strings.ToLower(errStr)
switch {
case looksLikeKiroInvalidGrantError(lower):
return kiroErrorClassification{Category: kiroErrorRefreshTokenInvalid, Message: errStr}
case looksLikeKiroMonthlyRequestCountError(errStr):
return kiroErrorClassification{Category: kiroErrorMonthlyRequest, Message: errStr}
case looksLikeKiroProfileError(lower):
return kiroErrorClassification{Category: kiroErrorProfileError, Message: errStr}
case looksLikeKiroOverageExhaustedError(lower):
return kiroErrorClassification{Category: kiroErrorOverageExhausted, Message: errStr}
case looksLikeKiroQuotaExhaustedError(lower):
return kiroErrorClassification{Category: kiroErrorQuotaExhausted, Message: errStr}
case strings.Contains(lower, "context deadline exceeded"),
strings.Contains(lower, "timeout"),
isNetErr(err):
return kiroErrorClassification{Category: kiroErrorUpstreamTransient, Message: errStr}
default:
return kiroErrorClassification{Category: kiroErrorUpstreamTransient, Message: errStr}
}
}
func classifyKiroBadRequest(trimmed, lower string) kiroErrorClassification {
switch {
case looksLikeKiroBadRequestSchemaError(lower):
return kiroErrorClassification{Category: kiroErrorBadRequestSchema, StatusCode: http.StatusBadRequest, Message: trimmed}
case looksLikeKiroBadRequestToolPairingError(lower):
return kiroErrorClassification{Category: kiroErrorBadRequestToolPairing, StatusCode: http.StatusBadRequest, Message: trimmed}
case looksLikeKiroBadRequestInvalidModelError(lower):
return kiroErrorClassification{Category: kiroErrorBadRequestInvalidModel, StatusCode: http.StatusBadRequest, Message: trimmed}
case looksLikeKiroInvalidGrantError(lower) || looksLikeKiroBadRequestAuthError(lower):
return kiroErrorClassification{Category: kiroErrorBadRequestAuth, StatusCode: http.StatusBadRequest, Message: trimmed}
case looksLikeKiroQuotaExhaustedError(lower) || looksLikeKiroMonthlyRequestCountError(trimmed):
return kiroErrorClassification{Category: kiroErrorBadRequestQuota, StatusCode: http.StatusBadRequest, Message: trimmed}
default:
return kiroErrorClassification{Category: kiroErrorBadRequestUnknown, StatusCode: http.StatusBadRequest, Message: trimmed}
}
}
func looksLikeKiroBadRequestSchemaError(lower string) bool {
if lower == "" {
return false
}
return strings.Contains(lower, "schema") ||
strings.Contains(lower, "inputschema") ||
strings.Contains(lower, "improperly formed request") ||
strings.Contains(lower, "additionalproperties") ||
(strings.Contains(lower, "properties") && strings.Contains(lower, "required"))
}
func looksLikeKiroBadRequestToolPairingError(lower string) bool {
if lower == "" {
return false
}
return strings.Contains(lower, "tool_use") ||
strings.Contains(lower, "tool_result") ||
strings.Contains(lower, "tooluseid") ||
strings.Contains(lower, "toolresults") ||
strings.Contains(lower, "must be paired") ||
strings.Contains(lower, "missing tool result")
}
func looksLikeKiroBadRequestInvalidModelError(lower string) bool {
if lower == "" {
return false
}
return strings.Contains(lower, "invalid model") ||
strings.Contains(lower, "invalid_model_id") ||
strings.Contains(lower, "model not supported") ||
strings.Contains(lower, "unsupportedmodel") ||
strings.Contains(lower, "modelid")
}
func looksLikeKiroBadRequestAuthError(lower string) bool {
if lower == "" {
return false
}
return strings.Contains(lower, "invalid token") ||
strings.Contains(lower, "expired token") ||
strings.Contains(lower, "access token") ||
strings.Contains(lower, "refresh token")
}
func looksLikeKiroInvalidGrantError(lower string) bool {
return strings.Contains(lower, "invalid_grant")
}
func looksLikeKiroMonthlyRequestCountError(body string) bool {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return false
}
if strings.Contains(trimmed, "MONTHLY_REQUEST_COUNT") {
return true
}
if !gjson.Valid(trimmed) {
return false
}
return gjson.Get(trimmed, "reason").String() == "MONTHLY_REQUEST_COUNT" ||
gjson.Get(trimmed, "error.reason").String() == "MONTHLY_REQUEST_COUNT"
}
func looksLikeKiroProfileError(lower string) bool {
if lower == "" {
return false
}
return (strings.Contains(lower, "profilearn") && strings.Contains(lower, "required")) ||
(strings.Contains(lower, "profile arn") && strings.Contains(lower, "required")) ||
(strings.Contains(lower, "profile") && strings.Contains(lower, "not found")) ||
(strings.Contains(lower, "invalid profile")) ||
(strings.Contains(lower, "listavailableprofiles"))
}
func looksLikeKiroQuotaExhaustedError(lower string) bool {
if lower == "" {
return false
}
return (strings.Contains(lower, "credit") && (strings.Contains(lower, "exhaust") || strings.Contains(lower, "depleted"))) ||
(strings.Contains(lower, "quota") && (strings.Contains(lower, "exhaust") || strings.Contains(lower, "exceeded") || strings.Contains(lower, "depleted"))) ||
(strings.Contains(lower, "usage limit") && (strings.Contains(lower, "reached") || strings.Contains(lower, "exceeded"))) ||
(strings.Contains(lower, "resource has been exhausted"))
}
func looksLikeKiroOverageExhaustedError(lower string) bool {
if lower == "" {
return false
}
return strings.Contains(lower, "overage") &&
(strings.Contains(lower, "exhaust") ||
strings.Contains(lower, "disabled") ||
strings.Contains(lower, "not enabled") ||
strings.Contains(lower, "not allowed") ||
strings.Contains(lower, "limit"))
}
func isNetErr(err error) bool {
var netErr net.Error
return errors.As(err, &netErr)
}