M5: 渠道体系(负载均衡+并发控制+健康检查+故障转移)
- channel.Candidates 按模型绑定取候选 + Pick 加权随机负载均衡 - TryAcquire 每渠道并发信号量, 满载溢出到其他候选 - HealthMonitor 后台定时探测, 连续失败进 cooldown, 恢复放回(可配 interval/threshold) - doProxy 遍历候选故障转移: 网络错误/429/5xx/超时且未写出响应头时安全重试; 400 等业务错误透传, 流式写出首字节后放弃重试 - 单测覆盖候选过滤/加权/并发/健康状态机 - E2E: 杀上游自动切换、cooldown、恢复、并发溢出 10/10 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -48,12 +48,45 @@ func upstreamURL(ch *store.Channel, path string) string {
|
||||
return strings.TrimRight(ch.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// doProxy 通用代理:替换 Authorization 为渠道密钥,转发请求;按 plan 决定路径与转换。
|
||||
func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) {
|
||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
||||
func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto string, body []byte, stream bool, sink *usageSink) {
|
||||
var lastStatus = http.StatusBadGateway
|
||||
var lastBody = []byte("all upstream channels failed")
|
||||
for _, ch := range cands {
|
||||
plan, err := prepareUpstream(ch.Provider, clientProto, body)
|
||||
if err != nil {
|
||||
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
||||
continue
|
||||
}
|
||||
release, ok := g.ch.TryAcquire(ch)
|
||||
if !ok {
|
||||
continue // 渠道满载,溢出到下一个
|
||||
}
|
||||
written, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
|
||||
release()
|
||||
if written {
|
||||
return
|
||||
}
|
||||
if !retry {
|
||||
// 非重试性失败(如 400):透传上游错误体
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Data(st, "application/json", b)
|
||||
g.recordError(c, ch, nil, now(), "upstream_http_"+strconv.Itoa(st))
|
||||
return
|
||||
}
|
||||
lastStatus, lastBody = st, b
|
||||
}
|
||||
// 全部候选重试性失败
|
||||
apiError(c, lastStatus, "upstream_error", string(lastBody))
|
||||
g.recordError(c, nil, nil, now(), "all_channels_failed")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
upKey, err := g.ch.UpstreamKey(ch)
|
||||
if err != nil {
|
||||
apiError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key")
|
||||
return
|
||||
return false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
|
||||
}
|
||||
|
||||
upBody := plan.body
|
||||
@@ -72,8 +105,7 @@ func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan,
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
apiError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request")
|
||||
return
|
||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+upKey)
|
||||
@@ -84,7 +116,6 @@ func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan,
|
||||
if plan.path == "/v1/messages" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
// 透传 OpenAI 生态请求头(组织/项目等)
|
||||
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
|
||||
if v := c.GetHeader(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
@@ -94,29 +125,20 @@ func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan,
|
||||
start := time.Now()
|
||||
resp, err := g.hc.Do(req)
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
msg := "Upstream request failed: " + err.Error()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
status = http.StatusGatewayTimeout
|
||||
msg = "Upstream request timed out"
|
||||
return false, true, http.StatusGatewayTimeout, []byte("upstream request timed out")
|
||||
}
|
||||
apiError(c, status, "upstream_error", msg)
|
||||
g.recordError(c, ch, nil, start, "upstream_error")
|
||||
return
|
||||
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 非 2xx:透传上游错误体,并记录 error 用量
|
||||
// 非 2xx:429/5xx 可重试;其余透传错误体
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
status := resp.StatusCode
|
||||
if status >= 500 {
|
||||
status = http.StatusBadGateway
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
return false, true, resp.StatusCode, errBody
|
||||
}
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil)
|
||||
g.recordError(c, ch, resp, start, "upstream_http_"+strconv.Itoa(resp.StatusCode))
|
||||
return
|
||||
return false, false, resp.StatusCode, errBody
|
||||
}
|
||||
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
@@ -126,6 +148,7 @@ func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan,
|
||||
} else {
|
||||
g.copyAndCapture(c, ch, resp.Body, start, plan.bodyConv, sink)
|
||||
}
|
||||
return true, false, http.StatusOK, nil
|
||||
}
|
||||
|
||||
// copyAndCapture 非流式:整体转发(可转换)+ 解析 usage + 记账。
|
||||
|
||||
Reference in New Issue
Block a user