mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +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 过滤规则。
123 lines
5.2 KiB
Go
123 lines
5.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// gatewayTransportErrorTempUnschedDuration is how long an account is temporarily
|
|
// unscheduled after a durable transport failure (matches the OpenAI-side
|
|
// openAITransportErrorTempUnschedDuration).
|
|
const gatewayTransportErrorTempUnschedDuration = 10 * time.Minute
|
|
|
|
// gatewayTransportFailoverBody is the Anthropic-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 gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upstream_error","message":"Upstream request failed"}}`)
|
|
|
|
// handleUpstreamTransportError handles a transport-level upstream failure on
|
|
// the Anthropic/Bedrock forward paths (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) —
|
|
// the caller passes path-specific fields (UpstreamURL, Passthrough) via
|
|
// event; identity, proxy attribution and classification fields are
|
|
// filled here from the same account snapshot that built the transport.
|
|
// A client disconnect is not recorded (see isClientCanceledTransportError);
|
|
// 2. for durable faults (expired/rejected proxy creds, dead proxy,
|
|
// DNS/routing) temporarily unschedules the account 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 the original
|
|
// 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).
|
|
func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error {
|
|
if isClientCanceledTransportError(ctx, err) {
|
|
return err
|
|
}
|
|
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
|
setOpsUpstreamError(c, 0, safeErr, "")
|
|
event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account)
|
|
event.Platform = account.Platform
|
|
event.AccountID = account.ID
|
|
event.AccountName = account.Name
|
|
event.UpstreamStatusCode = 0
|
|
event.Kind = "request_error"
|
|
event.Message = safeErr
|
|
appendOpsUpstreamError(c, event)
|
|
|
|
// 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 left local validation; count Ollama Cloud activity.
|
|
scheduleOllamaCloudUsageActivity(s.deferredService, account)
|
|
|
|
if classifyUpstreamTransportError(err).Persistent {
|
|
s.tempUnscheduleTransportError(ctx, account, safeErr)
|
|
}
|
|
|
|
return &UpstreamFailoverError{
|
|
StatusCode: http.StatusBadGateway,
|
|
ResponseBody: gatewayTransportFailoverBody,
|
|
}
|
|
}
|
|
|
|
// tempUnscheduleTransportError marks an account temporarily unschedulable
|
|
// after a durable transport failure. Unlike the OpenAI side there is no
|
|
// in-memory scheduler block on this path: the Anthropic/Bedrock scheduler
|
|
// reads the persisted temp-unschedulable state, so the DB write is the single
|
|
// source of truth (same as tempUnscheduleGoogleConfigError /
|
|
// tempUnscheduleEmptyResponse).
|
|
//
|
|
// Log semantics:
|
|
// - "gateway.account_temp_unscheduled_transport" — DB write succeeded.
|
|
// - "gateway.account_temp_unschedule_transport_failed" — DB write attempted
|
|
// but returned an error (the account remains schedulable).
|
|
func (s *GatewayService) tempUnscheduleTransportError(ctx context.Context, account *Account, safeErr string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
tempUnscheduleAccountForTransportError(ctx, s.accountRepo, account, safeErr)
|
|
}
|
|
|
|
// tempUnscheduleAccountForTransportError is the repo-level implementation
|
|
// shared by every forward path whose scheduler reads the persisted
|
|
// temp-unschedulable state (Anthropic/Bedrock and Gemini).
|
|
func tempUnscheduleAccountForTransportError(ctx context.Context, repo AccountRepository, account *Account, safeErr string) {
|
|
if account == nil || repo == nil {
|
|
return
|
|
}
|
|
until := time.Now().Add(gatewayTransportErrorTempUnschedDuration)
|
|
reason := "upstream transport error (proxy/network): " + safeErr
|
|
|
|
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAccountStateUpdateTimeout)
|
|
defer cancel()
|
|
if err := repo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil {
|
|
logger.L().With(zap.String("component", "service.gateway")).Warn(
|
|
"gateway.account_temp_unschedule_transport_failed",
|
|
zap.Int64("account_id", account.ID),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
logger.L().With(zap.String("component", "service.gateway")).Warn(
|
|
"gateway.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),
|
|
)
|
|
}
|