Files
CLIProxyAPI/internal/clienterror/client_error.go
sususu 579f5e30fb fix(auth): rotate credentials for unknown upstream failures
Stop treating an upstream 500 carrying "status":"UNKNOWN" as a request fault. It is an internal upstream failure, so the request now falls through to the next credential instead of failing immediately, and the resulting cooldown lands on the failing credential and model pair only, leaving sibling models on that credential selectable.

Move the store=false item-miss detection into the shared client-error package. The upstream sends that 404 as plain text rather than a JSON error body, so the structured identifiers could never match it and only the conductor recognized it. The proxy now reports it to the client, which is the only party able to rebuild the request without the stale item reference; a reconnect resends the full input and the conversation continues unchanged.
2026-08-06 20:49:23 +08:00

98 lines
2.8 KiB
Go

// Package clienterror classifies upstream failures caused by the client request.
package clienterror
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tidwall/gjson"
)
var requestFaultCodes = map[string]struct{}{
"cyber_policy": {},
"context_length_exceeded": {},
"message_too_big": {},
"string_above_max_length": {},
"invalid_prompt": {},
"invalid_value": {},
"unsupported_value": {},
"invalid_request_error": {},
"previous_response_not_found": {},
}
var requestFaultTypes = map[string]struct{}{
"invalid_request": {},
"invalid_request_error": {},
"bad_request_error": {},
"invalid_prompt": {},
}
// IsRequestFault reports whether an upstream failure is caused by the request
// and therefore must not rotate or penalize credentials.
func IsRequestFault(status int, err error) bool {
if status <= 0 && err != nil {
type statusCoder interface {
StatusCode() int
}
var statusErr statusCoder
if errors.As(err, &statusErr) && statusErr != nil {
status = statusErr.StatusCode()
}
}
if hasRequestFaultBody(err) {
return true
}
if err != nil && IsItemNotPersisted(err.Error()) {
return true
}
switch status {
case http.StatusBadRequest,
http.StatusConflict,
http.StatusRequestEntityTooLarge,
http.StatusUnprocessableEntity:
return true
default:
return false
}
}
// IsItemNotPersisted matches the upstream 404 raised when a request references a
// response item the upstream never stored because `store` was false. The upstream
// sends this as a plain-text message rather than a JSON body, so it cannot be
// recognized through the structured identifiers above.
//
// The request can only succeed once the client rebuilds it without the stale
// reference, so it is a request fault: rotating credentials cannot help, and the
// client must be told rather than left to retry the same broken input.
func IsItemNotPersisted(message string) bool {
lower := strings.ToLower(message)
return strings.Contains(lower, "item with id") &&
strings.Contains(lower, "not found") &&
strings.Contains(lower, "items are not persisted when `store` is set to false")
}
func hasRequestFaultBody(err error) bool {
if err == nil {
return false
}
body := strings.TrimSpace(err.Error())
if body == "" || !json.Valid([]byte(body)) {
return false
}
for _, path := range []string{"error.code", "code", "response.error.code", "body.error.code"} {
code := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String()))
if _, ok := requestFaultCodes[code]; ok {
return true
}
}
for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} {
errType := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String()))
if _, ok := requestFaultTypes[errType]; ok {
return true
}
}
return false
}