Files
dsfree2api/internal/upstream/errors.go
T
nyoungo c877c1bf8c fix(upstream): 额度耗尽立即轮换访客身份并识别 de/es/fr 额度文案
- 命中额度错误当场调用 RotateGuest(复用既有实现),不再等退避、也不再依赖 auto_refresh;后续路由与下一次请求直接用新访客身份
- quota_notice 信封(type=quota_notice / notice 对象)在通用 error 门之前判定,无 error 字段的额度通知不再被当成静默 delta
- 额度文案按站点语言识别:de guthaben/aufgebraucht、es agotado、fr épuisé/consomm、en quota/exhausted,并下钻 data/notice/quota_notice 嵌套字段
- cache message 失败与非 2xx 响应体也按同样规则归类,额度问题会走轮换与跨站 failover 而不是普通错误
2026-10-09 00:50:18 +08:00

239 lines
6.2 KiB
Go

package upstream
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// UpstreamError is any failure talking to the deepseek.* sites.
type UpstreamError struct {
Msg string
ContentStarted bool
Cause error
}
func (e *UpstreamError) Error() string {
if e.Msg != "" {
return e.Msg
}
if e.Cause != nil {
return e.Cause.Error()
}
return "upstream failed"
}
func (e *UpstreamError) Unwrap() error { return e.Cause }
// QuotaExhaustedError means the site ran out of free quota for this session.
type QuotaExhaustedError struct{ UpstreamError }
// SessionVerificationError means Turnstile/nonce verification must redone.
type SessionVerificationError struct{ UpstreamError }
func errf(format string, a ...any) *UpstreamError {
return &UpstreamError{Msg: fmt.Sprintf(format, a...)}
}
func newQuota(msg string) *QuotaExhaustedError {
return &QuotaExhaustedError{UpstreamError: UpstreamError{Msg: msg}}
}
func newSession(msg string) *SessionVerificationError {
return &SessionVerificationError{UpstreamError: UpstreamError{Msg: msg}}
}
// AsUpstream unwraps any error into an *UpstreamError.
func AsUpstream(err error) *UpstreamError {
if err == nil {
return nil
}
var ue *UpstreamError
if errors.As(err, &ue) {
return ue
}
return &UpstreamError{Msg: err.Error(), Cause: err}
}
func contentStarted(err error) bool {
var ue *UpstreamError
if errors.As(err, &ue) {
return ue.ContentStarted
}
return false
}
func isQuota(err error) bool {
var q *QuotaExhaustedError
return errors.As(err, &q)
}
func isSession(err error) bool {
var s *SessionVerificationError
return errors.As(err, &s)
}
// isSessionPayload classifies upstream JSON payloads that indicate the
// Turnstile cookie / nonce has to be re-established. It deliberately matches
// only explicit markers so a randomly embedded "nonce" string in echoed
// request data cannot trigger a costly re-solve.
func isSessionPayload(payload map[string]any) bool {
var texts []string
for _, k := range []string{"error", "message", "code"} {
if v, ok := payload[k]; ok && v != nil {
texts = append(texts, stringify(v))
}
}
if data, ok := payload["data"].(map[string]any); ok {
for _, k := range []string{"code", "message", "error"} {
if v, ok := data[k]; ok && v != nil {
texts = append(texts, stringify(v))
}
}
}
if notice, ok := payload["quota_notice"].(map[string]any); ok {
if v, ok := notice["message"]; ok {
texts = append(texts, stringify(v))
}
}
text := strings.ToLower(strings.Join(texts, " "))
for _, m := range []string{
"deepseek_ts_required",
"ts_required",
"nonce_failure",
"session_expired",
"forbidden",
"unauthorized",
"csrf",
"verification_required",
"sicherheitspruefung",
} {
if strings.Contains(text, m) {
return true
}
}
if v, ok := payload["ts_required"]; ok {
switch t := v.(type) {
case bool:
return t
case string:
return strings.TrimSpace(t) != "" && t != "0" && !strings.EqualFold(t, "false")
default:
return true
}
}
return false
}
// isConfigProblem reports errors that retrying cannot fix: the site stopped
// serving the AIPKit chat page (it now answers with a redirect or HTML), or
// the config points at something that does not exist.
func isConfigProblem(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "nonce fetch failed")
}
// isCacheEmptyProblem matches the site's cache-message rejection that a
// dropped config or a cookie re-solve never fixes on the same site: the
// mirrors carry independent sessions, so callers fail over instead of
// burning refresh cycles.
func isCacheEmptyProblem(err error) bool {
return strings.Contains(err.Error(), "empty_data_to_cache")
}
// hasQuotaEnvelope reports whether the payload carries the upstream quota
// notice envelope, independent of event type and of the site language. The
// front end keys on the same markers (type "quota_notice" or a "notice"
// object), so a notice that travels without an "error" field is still caught.
func hasQuotaEnvelope(payload map[string]any) bool {
if _, ok := payload["quota_notice"]; ok {
return true
}
if t, ok := payload["type"].(string); ok && strings.EqualFold(strings.TrimSpace(t), "quota_notice") {
return true
}
if n, ok := payload["notice"].(map[string]any); ok && len(n) > 0 {
return true
}
return false
}
// quotaMarkers are the localized "allowance spent" wordings the mirrors send.
// The same server plugin runs on de/es/fr with a different language, so the
// text check must not key on the German phrasing alone.
var quotaMarkers = []string{
// de
"guthaben",
"aufgebraucht",
// es
"agotado",
"saldo insuficiente",
// fr
"épuisé",
"epuis",
"consomm",
// en
"quota",
"exhausted",
"out of credit",
}
// quotaTexts collects every human readable field a quota notice can travel in.
func quotaTexts(payload map[string]any) []string {
var texts []string
collect := func(m map[string]any, keys ...string) {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
texts = append(texts, stringify(v))
}
}
}
collect(payload, "error", "message", "code")
for _, k := range []string{"data", "notice", "quota_notice"} {
if m, ok := payload[k].(map[string]any); ok {
collect(m, "error", "message", "code", "title", "text", "detail")
}
}
return texts
}
// isQuotaPayload classifies a quota-exhausted payload. The envelope is
// language independent; the wording check covers the phrasing each mirror
// actually sends (de/es/fr) plus the English fallback.
func isQuotaPayload(payload map[string]any) bool {
if hasQuotaEnvelope(payload) {
return true
}
text := strings.ToLower(strings.Join(quotaTexts(payload), " "))
if text == "" {
return false
}
for _, m := range quotaMarkers {
if strings.Contains(text, m) {
return true
}
}
return false
}
// jsonPayload decodes a response body into a payload map for classification;
// a non-object body yields an empty map so callers can safely probe it.
func jsonPayload(body []byte) map[string]any {
payload := map[string]any{}
_ = json.Unmarshal(body, &payload)
return payload
}
func stringify(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%v", t)
default:
return fmt.Sprintf("%v", t)
}
}