mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
客户端在响应提交前断开时,只有排队/换号阶段和 OpenAI 的兜底写入会标 499。 Claude 非流式与 Gemini 的 Chat Completions / Messages / Responses 兼容入口 在等待上游时断开,由 GatewayHandler.ensureForwardErrorResponse 补写 502; Gemini 原生入口什么都不写,落成 200 并被 ops 记成一条 provider 的 "Recovered upstream error";Antigravity 回写 502。这些行都按上游失败计入 SLA 与上游错误数。 - GatewayHandler.ensureForwardErrorResponse 补客户端断开检查,与 OpenAI 版一致 - GatewayHandler 的 Messages / ChatCompletions / Responses 与 Gemini 原生入口: 首次选号失败时先判断客户端断开;ChatCompletions / Responses 循环开头改用 failoverClientGone,不再直接 return 落成 200 - Gemini 原生入口:forward 失败时补 499 标记;并发槽错误改走 concurrencyErrorResponse 统一映射(断开 499、排队满/超时 429、其余 503) - Antigravity 上游响应前断开回写 499;googleapi 与 Antigravity 的 Google 错误 格式把 499 映射为 CANCELLED - OpenAI alpha search 非 failover 错误分支补断开检查 - Claude / Gemini / OpenAI 传输层错误:请求 context 已取消时不记 ops 上游事件 - ops:IgnoreContextCanceled 开启时跳过没有上游错误上下文的 499;带上游错误的 499(上游失败后客户端没等到换号结果就离开)仍按上游失败落库 计费不变:Gemini 流式中途断开的用量处理不在本次范围。新增 handler/service 单测覆盖各入口的断开场景与 ops 过滤规则。
221 lines
9.8 KiB
Go
221 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// openAITransportErrorTempUnschedDuration is how long an account is temporarily
|
|
// unscheduled after a durable transport failure (matches tokenRefreshTempUnschedDuration).
|
|
const openAITransportErrorTempUnschedDuration = 10 * time.Minute
|
|
|
|
// openAITransportFailoverBody is the OpenAI-format error body attached to the
|
|
// failover error for a transport-level failure. Kept identical to the legacy
|
|
// inline 502 body so the client-visible payload is unchanged if failover is
|
|
// ultimately exhausted.
|
|
var openAITransportFailoverBody = []byte(`{"error":{"type":"upstream_error","message":"Upstream request failed"}}`)
|
|
|
|
// upstreamTransportErrorClass describes how to react to a transport-level upstream
|
|
// failure — i.e. the HTTP round-trip never completed (proxy / DNS / TCP / TLS
|
|
// error, no HTTP status code received).
|
|
type upstreamTransportErrorClass struct {
|
|
// Persistent marks failures where retrying the same proxy/account is
|
|
// pointless: expired or rejected proxy credentials, a dead proxy endpoint,
|
|
// or DNS/routing failure. Such accounts should be temporarily unscheduled
|
|
// (and alerted on) instead of being repeatedly scheduled into hard failures.
|
|
Persistent bool
|
|
}
|
|
|
|
// persistentUpstreamTransportErrorMarkers are substrings (matched case-insensitively
|
|
// against the raw transport error) that indicate a durable proxy/network fault.
|
|
// Matched signals are intentionally specific failure *reasons*, not the operation
|
|
// (e.g. we match "connection refused", not "proxyconnect") so that a transient
|
|
// failure of the same operation (a proxy timeout) is NOT misclassified as durable.
|
|
var persistentUpstreamTransportErrorMarkers = []string{
|
|
"authentication failed", // SOCKS5 RFC1929 / proxy credentials rejected (expired account)
|
|
"proxy authentication required", // HTTP proxy 407
|
|
"connection refused", // proxy/upstream endpoint down
|
|
"no route to host",
|
|
"network is unreachable",
|
|
"no such host", // DNS resolution failure (bad/expired proxy hostname)
|
|
}
|
|
|
|
// classifyUpstreamTransportError decides whether a transport-level upstream error
|
|
// is durable (Persistent — evict the account + alert) or a transient blip
|
|
// (fail over to a healthy account but keep this one schedulable).
|
|
//
|
|
// Motivating incident: a SOCKS5 proxy whose subscription lapsed returned
|
|
// `username/password authentication failed`; the account was nonetheless
|
|
// rescheduled on every request, hard-failing users with 502s.
|
|
//
|
|
// Classification strategy (mirrors sanitizeStreamError in gateway_service.go):
|
|
// 1. Typed-error checks first (syscall constants, *net.DNSError) — portable and
|
|
// unambiguous.
|
|
// 2. String-marker fallback for errors that have no typed form (e.g. the plain
|
|
// string returned by golang.org/x/net/proxy for SOCKS5 credential rejection).
|
|
// The network-layer string markers ("connection refused", "no route to host",
|
|
// "network is unreachable", "no such host") are kept as a cross-platform safety
|
|
// net even though the typed checks should cover them on modern Go+Linux.
|
|
func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
|
|
if err == nil {
|
|
return upstreamTransportErrorClass{}
|
|
}
|
|
|
|
// — Typed checks (preferred) ——————————————————————————————————————————————
|
|
if errors.Is(err, syscall.ECONNREFUSED) ||
|
|
errors.Is(err, syscall.EHOSTUNREACH) ||
|
|
errors.Is(err, syscall.ENETUNREACH) {
|
|
return upstreamTransportErrorClass{Persistent: true}
|
|
}
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
|
return upstreamTransportErrorClass{Persistent: true}
|
|
}
|
|
|
|
// — String-marker fallback ————————————————————————————————————————————————
|
|
msg := strings.ToLower(err.Error())
|
|
for _, marker := range persistentUpstreamTransportErrorMarkers {
|
|
if strings.Contains(msg, marker) {
|
|
return upstreamTransportErrorClass{Persistent: true}
|
|
}
|
|
}
|
|
return upstreamTransportErrorClass{}
|
|
}
|
|
|
|
// isClientCanceledTransportError reports whether a transport-level failure was
|
|
// caused by the client disconnecting: the request context itself is canceled
|
|
// and the round-trip aborted with context.Canceled. Such a failure says nothing
|
|
// about the upstream, so it is not recorded as an Ops upstream error event.
|
|
func isClientCanceledTransportError(ctx context.Context, err error) bool {
|
|
return errors.Is(err, context.Canceled) && ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
|
}
|
|
|
|
// handleOpenAIUpstreamTransportError handles a transport-level upstream failure
|
|
// (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It:
|
|
// 1. records the failure in Ops error logs (status 0, kind=request_error),
|
|
// except when the client disconnected (see isClientCanceledTransportError);
|
|
// 2. for durable faults (expired/rejected proxy creds, dead proxy, DNS/routing)
|
|
// temporarily unschedules the account (DB + in-memory) and logs a stable
|
|
// warn event that alert rules can key on;
|
|
// 3. returns an error that is *UpstreamFailoverError (so the handler fails over
|
|
// to a healthy account) for all non-canceled errors, or a plain error for
|
|
// context.Canceled (client gone — no failover, no eviction).
|
|
//
|
|
// It deliberately does NOT write to the response: the handler owns the response
|
|
// (failover, or a protocol-correct error once failover is exhausted).
|
|
//
|
|
// passthrough tags the Ops error event for the OpenAI passthrough forward path.
|
|
func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error {
|
|
if isClientCanceledTransportError(ctx, err) {
|
|
return err
|
|
}
|
|
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
|
setOpsUpstreamError(c, 0, safeErr, "")
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
ProxyID: opsUpstreamProxyID(account),
|
|
ProxyName: opsUpstreamProxyName(account),
|
|
Platform: account.Platform,
|
|
AccountID: account.ID,
|
|
AccountName: account.Name,
|
|
UpstreamStatusCode: 0,
|
|
Passthrough: passthrough,
|
|
Kind: "request_error",
|
|
Message: safeErr,
|
|
})
|
|
|
|
// Client disconnected: do NOT fail over to another account and do NOT evict
|
|
// this one — the upstream never had a chance to exhibit a fault.
|
|
if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) {
|
|
return err
|
|
}
|
|
|
|
// Transport attempt reached the network path; count as Ollama Cloud / OpenCode Go activity.
|
|
if s != nil {
|
|
scheduleOllamaCloudUsageActivity(s.deferredService, account)
|
|
scheduleOpenCodeGoUsageActivity(s.deferredService, account)
|
|
}
|
|
|
|
// 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
|
|
var pluginErr *PluginTransportError
|
|
if errors.As(err, &pluginErr) && pluginErr.RequestSent {
|
|
return err
|
|
}
|
|
|
|
if classifyUpstreamTransportError(err).Persistent {
|
|
s.tempUnscheduleOpenAITransportError(ctx, account, safeErr)
|
|
}
|
|
|
|
return &UpstreamFailoverError{
|
|
StatusCode: http.StatusBadGateway,
|
|
ResponseBody: openAITransportFailoverBody,
|
|
}
|
|
}
|
|
|
|
// tempUnscheduleOpenAITransportError marks an account temporarily unschedulable
|
|
// after a durable transport failure, both persistently (DB, survives restart)
|
|
// and in-memory (immediate scheduler effect before the DB/account cache propagates).
|
|
//
|
|
// Log semantics:
|
|
// - "openai.account_temp_unscheduled_transport" — emitted ONLY after a
|
|
// successful DB write (both in-memory + persisted).
|
|
// - "openai.account_temp_unscheduled_transport_memory_only" — emitted when
|
|
// accountRepo is nil (in-memory only; no persistence).
|
|
// - "openai.account_temp_unscheduled_transport_failed" — DB write attempted
|
|
// but returned an error.
|
|
func (s *OpenAIGatewayService) tempUnscheduleOpenAITransportError(ctx context.Context, account *Account, safeErr string) {
|
|
if s == nil || account == nil {
|
|
return
|
|
}
|
|
until := time.Now().Add(openAITransportErrorTempUnschedDuration)
|
|
reason := "upstream transport error (proxy/network): " + safeErr
|
|
|
|
// Immediate in-memory block so this process skips the account until the
|
|
// persisted cooldown is visible on the scheduling Account. Selection is
|
|
// fail-open: empty snapshot/DB cooldown fields drop a stale local block.
|
|
s.BlockAccountScheduling(account, until, "transport_error")
|
|
|
|
if s.accountRepo == nil {
|
|
// No DB configured — block is in-memory only; emit a distinct event so
|
|
// operators are not misled into thinking the block survived a restart.
|
|
logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
|
|
"openai.account_temp_unscheduled_transport_memory_only",
|
|
zap.Int64("account_id", account.ID),
|
|
zap.String("account_name", account.Name),
|
|
zap.String("platform", account.Platform),
|
|
zap.Time("until", until),
|
|
zap.String("reason", reason),
|
|
)
|
|
return
|
|
}
|
|
|
|
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAccountStateUpdateTimeout)
|
|
defer cancel()
|
|
if err := s.accountRepo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil {
|
|
logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
|
|
"openai.account_temp_unscheduled_transport_failed",
|
|
zap.Int64("account_id", account.ID),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
|
|
// DB write succeeded: both in-memory and persisted.
|
|
logger.L().With(zap.String("component", "service.openai_gateway")).Warn(
|
|
"openai.account_temp_unscheduled_transport",
|
|
zap.Int64("account_id", account.ID),
|
|
zap.String("account_name", account.Name),
|
|
zap.String("platform", account.Platform),
|
|
zap.Time("until", until),
|
|
zap.String("reason", reason),
|
|
)
|
|
}
|