New
This commit is contained in:
101
internal/errors/api_error.go
Normal file
101
internal/errors/api_error.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Filename: internal/error/api_error.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIError defines a standard error structure for API responses.
|
||||
type APIError struct {
|
||||
HTTPStatus int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Predefined API errors
|
||||
var (
|
||||
ErrBadRequest = &APIError{HTTPStatus: http.StatusBadRequest, Code: "BAD_REQUEST", Message: "Invalid request parameters"}
|
||||
ErrInvalidJSON = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_JSON", Message: "Invalid JSON format"}
|
||||
ErrValidation = &APIError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_FAILED", Message: "Input validation failed"}
|
||||
ErrDuplicateResource = &APIError{HTTPStatus: http.StatusConflict, Code: "DUPLICATE_RESOURCE", Message: "Resource already exists"}
|
||||
ErrResourceNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
|
||||
ErrInternalServer = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "INTERNAL_SERVER_ERROR", Message: "An unexpected error occurred"}
|
||||
ErrDatabase = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "DATABASE_ERROR", Message: "Database operation failed"}
|
||||
ErrUnauthorized = &APIError{HTTPStatus: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "Authentication failed"}
|
||||
ErrForbidden = &APIError{HTTPStatus: http.StatusForbidden, Code: "FORBIDDEN", Message: "You do not have permission to access this resource"}
|
||||
ErrTaskInProgress = &APIError{HTTPStatus: http.StatusConflict, Code: "TASK_IN_PROGRESS", Message: "A task is already in progress"}
|
||||
ErrBadGateway = &APIError{HTTPStatus: http.StatusBadGateway, Code: "BAD_GATEWAY", Message: "Upstream service error"}
|
||||
ErrNoActiveKeys = &APIError{HTTPStatus: http.StatusServiceUnavailable, Code: "NO_ACTIVE_KEYS", Message: "No active API keys available for this group"}
|
||||
ErrMaxRetriesExceeded = &APIError{HTTPStatus: http.StatusBadGateway, Code: "MAX_RETRIES_EXCEEDED", Message: "Request failed after maximum retries"}
|
||||
ErrNoKeysAvailable = &APIError{HTTPStatus: http.StatusServiceUnavailable, Code: "NO_KEYS_AVAILABLE", Message: "No API keys available to process the request"}
|
||||
|
||||
ErrStateConflict = &APIError{HTTPStatus: http.StatusConflict, Code: "STATE_CONFLICT", Message: "The operation cannot be completed due to the current state of the resource."}
|
||||
ErrGroupNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "GROUP_NOT_FOUND", Message: "The specified group was not found."}
|
||||
ErrPermissionDenied = &APIError{HTTPStatus: http.StatusForbidden, Code: "PERMISSION_DENIED", Message: "Permission denied for this operation."}
|
||||
ErrConfigurationError = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "CONFIGURATION_ERROR", Message: "A configuration error prevents this request from being processed."}
|
||||
|
||||
ErrStateConflictMasterRevoked = &APIError{HTTPStatus: http.StatusConflict, Code: "STATE_CONFLICT_MASTER_REVOKED", Message: "Cannot perform this operation on a revoked key."}
|
||||
ErrNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
|
||||
ErrNoKeysMatchFilter = &APIError{HTTPStatus: http.StatusBadRequest, Code: "NO_KEYS_MATCH_FILTER", Message: "No keys were found that match the provided filter criteria."}
|
||||
)
|
||||
|
||||
// NewAPIError creates a new APIError with a custom message.
|
||||
func NewAPIError(base *APIError, message string) *APIError {
|
||||
return &APIError{
|
||||
HTTPStatus: base.HTTPStatus,
|
||||
Code: base.Code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAPIErrorWithUpstream creates a new APIError specifically for wrapping raw upstream errors.
|
||||
func NewAPIErrorWithUpstream(statusCode int, code string, upstreamMessage string) *APIError {
|
||||
return &APIError{
|
||||
HTTPStatus: statusCode,
|
||||
Code: code,
|
||||
Message: upstreamMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDBError intelligently converts a GORM error into a standard APIError.
|
||||
func ParseDBError(err error) *APIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" { // unique_violation
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
}
|
||||
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) {
|
||||
if mysqlErr.Number == 1062 { // Duplicate entry
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
}
|
||||
|
||||
// Generic check for SQLite
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") {
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
|
||||
return ErrDatabase
|
||||
}
|
||||
19
internal/errors/errors.go
Normal file
19
internal/errors/errors.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Filename: internal/errors/errors.go
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
std_errors "errors" // 为标准库errors包指定别名
|
||||
)
|
||||
|
||||
func Is(err, target error) bool {
|
||||
return std_errors.Is(err, target)
|
||||
}
|
||||
|
||||
func As(err error, target any) bool {
|
||||
return std_errors.As(err, target)
|
||||
}
|
||||
|
||||
func Unwrap(err error) error {
|
||||
return std_errors.Unwrap(err)
|
||||
}
|
||||
111
internal/errors/upstream_errors.go
Normal file
111
internal/errors/upstream_errors.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Filename: internal/errors/upstream_errors.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: [Future Evolution] This file establishes the new, granular error classification framework.
|
||||
// The next step is to refactor the handleKeyUsageEvent method in APIKeyService to utilize these new
|
||||
// classifiers and implement the corresponding actions:
|
||||
//
|
||||
// 1. On IsPermanentUpstreamError:
|
||||
// - Set mapping status to models.StatusBanned.
|
||||
// - Set the master APIKey's status to models.MasterStatusRevoked.
|
||||
// - This is a "one-strike, you're out" policy for definitively invalid keys.
|
||||
//
|
||||
// 2. On IsTemporaryUpstreamError:
|
||||
// - Increment mapping.ConsecutiveErrorCount.
|
||||
// - Check against the blacklist threshold to potentially set status to models.StatusDisabled.
|
||||
// - This is for recoverable errors that are the key's fault (e.g., quota limits).
|
||||
//
|
||||
// 3. On ALL other upstream errors (that are not Permanent or Temporary):
|
||||
// - These are treated as "Truly Ignorable" from the key's perspective (e.g., 503 Service Unavailable).
|
||||
// - Do NOT increment the error count. Only update LastUsedAt.
|
||||
// - This prevents good keys from being punished for upstream service instability.
|
||||
|
||||
// --- 1. Permanent Errors ---
|
||||
// Errors that indicate the API Key itself is permanently invalid.
|
||||
// Action: Ban mapping, Revoke Master Key.
|
||||
var permanentErrorSubstrings = []string{
|
||||
"invalid api key",
|
||||
"api key not valid",
|
||||
"api key suspended",
|
||||
"API Key not found",
|
||||
"api key expired",
|
||||
"permission denied", // Often indicates the key lacks permissions for the target model/service.
|
||||
"permission_denied", // Catches the 'status' field in Google's JSON error, e.g., "status": "PERMISSION_DENIED".
|
||||
"service_disabled", // Catches the 'reason' field for disabled APIs, e.g., "reason": "SERVICE_DISABLED".
|
||||
"api has not been used",
|
||||
}
|
||||
|
||||
// --- 2. Temporary Errors ---
|
||||
// Errors that are attributable to the key's state but are recoverable over time.
|
||||
// Action: Increment consecutive error count, potentially disable the key.
|
||||
var temporaryErrorSubstrings = []string{
|
||||
"quota",
|
||||
"limit reached",
|
||||
"insufficient",
|
||||
"billing",
|
||||
"exceeded",
|
||||
"too many requests",
|
||||
}
|
||||
|
||||
// --- 3. Unretryable Request Errors ---
|
||||
// Errors indicating a problem with the user's request, not the key. Retrying with a new key is pointless.
|
||||
// Action: Abort the retry loop immediately in ProxyHandler.
|
||||
var unretryableRequestErrorSubstrings = []string{
|
||||
"invalid content",
|
||||
"invalid argument",
|
||||
"malformed",
|
||||
"unsupported",
|
||||
"invalid model",
|
||||
}
|
||||
|
||||
// --- 4. Ignorable Client/Network Errors ---
|
||||
// Network-level errors, typically caused by the client disconnecting.
|
||||
// Action: Ignore for logging and metrics purposes.
|
||||
var clientNetworkErrorSubstrings = []string{
|
||||
"context canceled",
|
||||
"connection reset by peer",
|
||||
"broken pipe",
|
||||
"use of closed network connection",
|
||||
"request canceled",
|
||||
}
|
||||
|
||||
// IsPermanentUpstreamError checks if an upstream error indicates the key is permanently invalid.
|
||||
func IsPermanentUpstreamError(msg string) bool {
|
||||
return containsSubstring(msg, permanentErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsTemporaryUpstreamError checks if an upstream error is due to temporary, key-specific limits.
|
||||
func IsTemporaryUpstreamError(msg string) bool {
|
||||
return containsSubstring(msg, temporaryErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsUnretryableRequestError checks if an upstream error is due to a malformed user request.
|
||||
func IsUnretryableRequestError(msg string) bool {
|
||||
return containsSubstring(msg, unretryableRequestErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsClientNetworkError checks if an error is a common, ignorable client-side network issue.
|
||||
func IsClientNetworkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return containsSubstring(err.Error(), clientNetworkErrorSubstrings)
|
||||
}
|
||||
|
||||
// containsSubstring is a helper function to avoid code repetition.
|
||||
func containsSubstring(s string, substrings []string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
lowerS := strings.ToLower(s)
|
||||
for _, sub := range substrings {
|
||||
if strings.Contains(lowerS, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
79
internal/errors/upstream_parser.go
Normal file
79
internal/errors/upstream_parser.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxErrorBodyLength defines the maximum length of an error message to be stored or returned.
|
||||
maxErrorBodyLength = 2048
|
||||
)
|
||||
|
||||
// standardErrorResponse matches formats like: {"error": {"message": "..."}}
|
||||
type standardErrorResponse struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// vendorErrorResponse matches formats like: {"error_msg": "..."}
|
||||
type vendorErrorResponse struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// simpleErrorResponse matches formats like: {"error": "..."}
|
||||
type simpleErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// rootMessageErrorResponse matches formats like: {"message": "..."}
|
||||
type rootMessageErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ParseUpstreamError attempts to parse a structured error message from an upstream response body
|
||||
func ParseUpstreamError(body []byte) string {
|
||||
// 1. Attempt to parse the standard OpenAI/Gemini format.
|
||||
var stdErr standardErrorResponse
|
||||
if err := json.Unmarshal(body, &stdErr); err == nil {
|
||||
if msg := strings.TrimSpace(stdErr.Error.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt to parse vendor-specific format (e.g., Baidu).
|
||||
var vendorErr vendorErrorResponse
|
||||
if err := json.Unmarshal(body, &vendorErr); err == nil {
|
||||
if msg := strings.TrimSpace(vendorErr.ErrorMsg); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attempt to parse simple error format.
|
||||
var simpleErr simpleErrorResponse
|
||||
if err := json.Unmarshal(body, &simpleErr); err == nil {
|
||||
if msg := strings.TrimSpace(simpleErr.Error); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Attempt to parse root-level message format.
|
||||
var rootMsgErr rootMessageErrorResponse
|
||||
if err := json.Unmarshal(body, &rootMsgErr); err == nil {
|
||||
if msg := strings.TrimSpace(rootMsgErr.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Graceful Degradation: If all parsing fails, return the raw (but safe) body.
|
||||
return truncateString(string(body), maxErrorBodyLength)
|
||||
}
|
||||
|
||||
// truncateString ensures a string does not exceed a maximum length.
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) > maxLength {
|
||||
return s[:maxLength]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user