mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(gateway): prevent double-write on error passthrough responses
Service layer writes a complete JSON error response then returns error. Handler's ensureForwardErrorResponse couldn't distinguish this from "no response written" and appended an SSE event, corrupting the body. Use gin.Context flag: service marks MarkResponseCommitted(c) after writing, ensureForwardErrorResponse checks IsResponseCommitted(c) and skips. Zero function signature changes, zero error wrapping.
This commit is contained in:
@@ -1605,6 +1605,9 @@ func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarte
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
if service.IsResponseCommitted(c) {
|
||||
return false
|
||||
}
|
||||
if c.Writer.Written() {
|
||||
streamStarted = true
|
||||
}
|
||||
|
||||
@@ -1847,11 +1847,9 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
// 旧实现在 Writer.Written 时直接 return false,导致 ping 已 flush 之后的
|
||||
// 上游错误(http2 timeout、连接中断等)完全无法把错误传给客户端——
|
||||
// HTTP 200 已锁死,TCP 直接 EOF,Codex CLI 报 "stream closed before response.completed"。
|
||||
// 这里改成:Writer 已写过时强制走 streamStarted 分支,让
|
||||
// handleStreamingAwareError 通过 SSE 发协议合规的 response.failed。
|
||||
if service.IsResponseCommitted(c) {
|
||||
return false
|
||||
}
|
||||
if c.Writer.Written() {
|
||||
streamStarted = true
|
||||
}
|
||||
|
||||
@@ -3657,6 +3657,7 @@ func (s *AntigravityGatewayService) WriteMappedClaudeError(c *gin.Context, accou
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error {
|
||||
MarkResponseCommitted(c)
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
logBody, maxBytes := s.getLogConfig()
|
||||
|
||||
@@ -251,6 +251,89 @@ func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testi
|
||||
assert.False(t, exists, "OpsSkipPassthroughKey should NOT be set when skip_monitoring=false")
|
||||
}
|
||||
|
||||
// ---- ResponseCommittedKey: service 层写完错误响应后标记,handler 层检查跳过兜底写入 ----
|
||||
|
||||
func TestHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &GatewayService{}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 100, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsResponseCommitted(c), "non-failover error path must mark response committed")
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
}
|
||||
|
||||
func TestHandleErrorResponse_PassthroughRuleSetsCommitted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
ruleSvc := &ErrorPassthroughService{}
|
||||
ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{
|
||||
newNonFailoverPassthroughRule(http.StatusBadRequest, "temperature", http.StatusBadRequest, "参数错误"),
|
||||
})
|
||||
BindErrorPassthroughService(c, ruleSvc)
|
||||
|
||||
svc := &GatewayService{}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 200, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsResponseCommitted(c), "passthrough rule path must mark response committed")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField := payload["error"].(map[string]any)
|
||||
assert.Equal(t, "参数错误", errField["message"])
|
||||
}
|
||||
|
||||
func TestOpenAIHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"rate limit exceeded"}}`))),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsResponseCommitted(c), "OpenAI non-failover path must mark response committed")
|
||||
}
|
||||
|
||||
func TestGeminiWriteGeminiMappedError_SetsResponseCommitted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &GeminiMessagesCompatService{}
|
||||
body := []byte(`{"error":{"message":"invalid field"}}`)
|
||||
account := &Account{ID: 102, Platform: PlatformGemini, Type: AccountTypeAPIKey}
|
||||
|
||||
err := svc.writeGeminiMappedError(c, account, http.StatusBadRequest, "req-99", body)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsResponseCommitted(c), "Gemini path must mark response committed")
|
||||
}
|
||||
|
||||
func newNonFailoverPassthroughRule(statusCode int, keyword string, respCode int, customMessage string) *model.ErrorPassthroughRule {
|
||||
return &model.ErrorPassthroughRule{
|
||||
ID: 1,
|
||||
|
||||
@@ -7353,6 +7353,8 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: body}
|
||||
}
|
||||
|
||||
MarkResponseCommitted(c)
|
||||
|
||||
// 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端)
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
@@ -7476,6 +7478,7 @@ func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *ht
|
||||
// OAuth 403:标记账号异常
|
||||
// API Key 未配置错误码:仅返回错误,不标记账号
|
||||
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
||||
MarkResponseCommitted(c)
|
||||
// Capture upstream error body before side-effects consume the stream.
|
||||
respBody, _ := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -1700,6 +1700,7 @@ func sanitizeUpstreamErrorMessage(msg string) string {
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error {
|
||||
MarkResponseCommitted(c)
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := ""
|
||||
|
||||
@@ -3552,6 +3552,7 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
|
||||
account *Account,
|
||||
requestBody []byte,
|
||||
) error {
|
||||
MarkResponseCommitted(c)
|
||||
body := s.readUpstreamErrorBody(resp)
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
@@ -4359,6 +4360,8 @@ func (s *OpenAIGatewayService) handleErrorResponse(
|
||||
}
|
||||
}
|
||||
|
||||
MarkResponseCommitted(c)
|
||||
|
||||
// Return appropriate error response
|
||||
var errType, errMsg string
|
||||
var statusCode int
|
||||
@@ -4498,6 +4501,8 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
|
||||
}
|
||||
}
|
||||
|
||||
MarkResponseCommitted(c)
|
||||
|
||||
// Map status code to error type and write response
|
||||
errType := "api_error"
|
||||
switch {
|
||||
|
||||
@@ -34,6 +34,10 @@ const (
|
||||
|
||||
// Client-side configuration denials should remain visible in ops_error_logs,
|
||||
// but should be excluded from SLA/error-rate calculations.
|
||||
// ResponseCommittedKey 由 handleErrorResponse 系列函数在写完 HTTP 错误响应后设置。
|
||||
// ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。
|
||||
ResponseCommittedKey = "response_committed"
|
||||
|
||||
OpsClientBusinessLimitedKey = "ops_client_business_limited"
|
||||
OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason"
|
||||
OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction"
|
||||
@@ -43,6 +47,17 @@ const (
|
||||
OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied"
|
||||
)
|
||||
|
||||
func MarkResponseCommitted(c *gin.Context) { c.Set(ResponseCommittedKey, true) }
|
||||
|
||||
func IsResponseCommitted(c *gin.Context) bool {
|
||||
v, ok := c.Get(ResponseCommittedKey)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func SetOpsLatencyMs(c *gin.Context, key string, value int64) {
|
||||
if c == nil || strings.TrimSpace(key) == "" || value < 0 {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user