fix: 修掉模型调用链路上的四处失效逻辑
- 超时:NewGateway 里 gw_timeout*time.Second 把已是 Duration 的值二次换算, int64 溢出后无论 OT_PROXY_TIMEOUT 填多少都等于没有超时,渠道 timeout_ms 也从没被读过。现在非流式用 context deadline 约束整段时长,流式只约束首包 (Transport.ResponseHeaderTimeout),正文不限时长,长回答不会被砍。 - 断连:上游请求改用 NewRequestWithContext 绑定客户端 context,客户端断开即 取消上游并记 canceled,不再空转生成继续付费;PATCH 已写 200 头后不再往 响应里追加错误体。 - 白名单:APIKey.AllowedModels 以前只存不用,现在 Auth 注入 context 并参与 判定(用户黑名单 > 密钥白名单 > 用户白名单 > 全局)。 - 选路:模型已有渠道绑定时只用绑定渠道,都不可用直接 503 no_channel,不再 把请求发给不认识它的渠道;新增运行期健康(连续 3 次网络错误/超时/429/5xx 进 30s 冷却,成功清零),与 60s 一次的 /models 探测互补。 - 密钥 API:PATCH 回显更新后的完整对象;限额填 0 统一视为不限制(原来 token 配额存 0 会让密钥第一次请求就被 429 打死,而请求配额存 0 又等于 不限制,两个字段语义相反);前端编辑框总是提交白名单,清空能真的取消。 测试:新增 proxy 链路集成测试(超时生效、流式不被截断、断连取消并记账、 密钥白名单 403、连续 429 后候选剔除)与 channel 用例(绑定模型不退全局、 冷却阈值与到期恢复、成功清零)。
This commit is contained in:
1 parent
d911881499
commit
e865f69a41
10 files changed
+659
-69
No files matched your search
@@ -3,9 +3,11 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -187,8 +189,17 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
idx := (startIdx + offset) % len(cands)
|
||||
cand := cands[idx]
|
||||
ch := cand.Channel
|
||||
// 客户端已断开:上游请求会随 context 立刻失败,换渠道也没有意义
|
||||
if c.Request.Context().Err() != nil {
|
||||
g.finishUsage(c, ch, now(), store.UsageStatusCanceled, "client_disconnect")
|
||||
return
|
||||
}
|
||||
if g.ch.InCooldown(ch.ID) {
|
||||
continue // 本次请求期间已被并发请求判入运行期冷却
|
||||
}
|
||||
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
|
||||
if err != nil {
|
||||
// 请求体转换失败与渠道健康无关,不换渠道计数
|
||||
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -196,9 +207,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
if !ok {
|
||||
continue // 渠道满载,溢出到下一个
|
||||
}
|
||||
written, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
|
||||
done, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
|
||||
release()
|
||||
if written {
|
||||
if done {
|
||||
return
|
||||
}
|
||||
if !retry {
|
||||
@@ -208,6 +219,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
g.recordError(c, ch, nil, now(), "upstream_http_"+strconv.Itoa(st))
|
||||
return
|
||||
}
|
||||
g.ch.ReportFailure(ch) // 网络错误/超时/429/5xx:累计运行期失败
|
||||
lastStatus, lastBody = st, b
|
||||
}
|
||||
// 全部候选重试性失败
|
||||
@@ -216,8 +228,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
}
|
||||
|
||||
// proxyOne 对单个渠道执行一次代理。
|
||||
// 返回:written=是否已写客户端响应;retry=是否可安全换渠道重试;status+respBody=失败信息。
|
||||
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) (written bool, retry bool, status int, respBody []byte) {
|
||||
// 返回:done=本次请求已终结(客户端响应已写出或已记账,停止故障转移);
|
||||
// retry=是否可安全换渠道重试;status+respBody=失败信息。
|
||||
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) (done bool, retry bool, status int, respBody []byte) {
|
||||
upKey, err := g.ch.UpstreamKey(ch)
|
||||
if err != nil {
|
||||
return false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
|
||||
@@ -235,7 +248,18 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
// 上游请求绑定客户端 context:客户端断开即取消上游,不再空转生成、继续付费。
|
||||
// 非流式再叠加渠道超时作为整段硬上限;流式只靠 Transport.ResponseHeaderTimeout
|
||||
// 约束首包,正文时长不设限(长回答不能被超时砍断)。
|
||||
limit := g.channelTimeout(ch)
|
||||
ctx := c.Request.Context()
|
||||
if !stream && limit > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, limit)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||
}
|
||||
@@ -255,8 +279,13 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := g.hc.Do(req)
|
||||
resp, err := g.clientFor(limit).Do(req)
|
||||
if err != nil {
|
||||
// 客户端断开导致的取消:就地记账并终结本次请求(不计入渠道失败,也不换渠道)
|
||||
if c.Request.Context().Err() != nil {
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return true, false, 0, nil
|
||||
}
|
||||
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -269,6 +298,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
}
|
||||
return false, false, resp.StatusCode, errBody
|
||||
}
|
||||
g.ch.ReportSuccess(ch)
|
||||
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
c.Status(http.StatusOK)
|
||||
@@ -284,8 +314,15 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
apiError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response")
|
||||
g.recordError(c, ch, nil, start, "read_error")
|
||||
// 走到这里响应头已按 200 写出,无法再回错误体,只按中断原因记账
|
||||
switch {
|
||||
case c.Request.Context().Err() != nil:
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
g.finishUsage(c, ch, start, store.UsageStatusError, "upstream_timeout")
|
||||
default:
|
||||
g.finishUsage(c, ch, start, store.UsageStatusError, "read_error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if usageRaw := extractUsage(data); usageRaw != nil && sink != nil {
|
||||
|
||||
Reference in new issue
Block a user