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:
Sakurasan
2026-08-15 15:57:48 +08:00
co-authored by Claude
parent ec4de8d913
commit 0637ce0a51
11 changed files with 454 additions and 97 deletions
+45 -22
View File
@@ -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 + 记账。