fix(gateway): handle Anthropic SSE overload errors

This commit is contained in:
wucm667
2026-08-13 02:43:03 +08:00
parent 5935e674a8
commit 76a13a5a8d
2 changed files with 128 additions and 4 deletions
+14 -4
View File
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/gin-gonic/gin"
)
@@ -800,14 +801,23 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
var firstTokenMs *int
var clientDisconnect bool
if reqStream {
writerSizeBeforeStream := c.Writer.Size()
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode)
if err != nil {
var sseErr *sseStreamErrorEventError
if errors.As(err, &sseErr) {
// 上游 HTTP 200 + SSE 流体内出现 event:error 帧。
// 保留 StatusCode=403 以兼容既有 failover/客户端响应语义,
// 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。
body := []byte(sseErr.RawData)
semanticStatus := http.StatusForbidden
if c.Writer.Size() == writerSizeBeforeStream && gjson.GetBytes(body, "error.type").String() == "overloaded_error" {
semanticStatus = 529
syntheticResp := &http.Response{
StatusCode: semanticStatus,
Header: resp.Header.Clone(),
Body: io.NopCloser(bytes.NewReader(body)),
}
s.handleFailoverSideEffects(ctx, syntheticResp, account, reqModel)
}
upstreamMsg := sanitizeUpstreamErrorMessage(
strings.TrimSpace(extractUpstreamErrorMessage(body)),
@@ -826,7 +836,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: 403,
UpstreamStatusCode: semanticStatus,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "stream_error",
Message: upstreamMsg,
@@ -840,7 +850,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
)
return nil, &UpstreamFailoverError{
StatusCode: 403,
StatusCode: semanticStatus,
ResponseBody: body,
}
}
@@ -8,12 +8,37 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type gatewayForwardErrorPolicyRepoStub struct {
AccountRepository
tempCalls int
modelRateLimitCalls []gatewayForwardModelRateLimitCall
}
type gatewayForwardModelRateLimitCall struct {
accountID int64
scope string
}
func (r *gatewayForwardErrorPolicyRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
r.tempCalls++
return nil
}
func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, _ time.Time, _ ...string) error {
r.modelRateLimitCalls = append(r.modelRateLimitCalls, gatewayForwardModelRateLimitCall{
accountID: id,
scope: scope,
})
return nil
}
// 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
// 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
// 返回,供 handler 照常提交 usage 记录。
@@ -183,6 +208,95 @@ func TestGatewayService_Forward_FailoverErrorKeepsNilResult(t *testing.T) {
require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费")
}
func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
require.NoError(t, err)
const errorJSON = `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_01"}`
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("event: error\ndata: " + errorJSON + "\n\n")),
}}
repo := &gatewayForwardErrorPolicyRepoStub{}
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
svc := &GatewayService{
cfg: cfg,
responseHeaderFilter: compileResponseHeaderFilter(cfg),
httpUpstream: upstream,
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
deferredService: &DeferredService{},
}
account := newAnthropicOAuthAccountForPartialUsageTest()
account.Credentials["temp_unschedulable_enabled"] = true
account.Credentials["temp_unschedulable_rules"] = []any{map[string]any{
"error_code": float64(529),
"keywords": []any{"Overloaded"},
"duration_minutes": float64(10),
}}
result, err := svc.Forward(context.Background(), c, account, parsed)
require.Error(t, err)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, 529, failoverErr.StatusCode)
require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
require.Len(t, repo.modelRateLimitCalls, 1, "synthetic 529 must participate in temp-unschedulable rules")
require.Equal(t, account.ID, repo.modelRateLimitCalls[0].accountID)
require.Equal(t, parsed.Model, repo.modelRateLimitCalls[0].scope)
require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover")
}
func TestGatewayService_Forward_PostOutputSSEOverloadedErrorKeepsExistingStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
require.NoError(t, err)
const errorJSON = `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`
fixture := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
"event: error\ndata: " + errorJSON + "\n\n"
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(fixture)),
}}
repo := &gatewayForwardErrorPolicyRepoStub{}
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
svc := &GatewayService{
cfg: cfg,
responseHeaderFilter: compileResponseHeaderFilter(cfg),
httpUpstream: upstream,
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
deferredService: &DeferredService{},
}
result, err := svc.Forward(context.Background(), c, newAnthropicOAuthAccountForPartialUsageTest(), parsed)
require.Error(t, err)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
require.Zero(t, repo.tempCalls)
require.Contains(t, rec.Body.String(), "message_start")
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(t *testing.T) {
gin.SetMode(gin.TestMode)