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:
erio
2026-06-10 00:15:51 +08:00
parent 63d95b4ec7
commit 6c88631690
8 changed files with 114 additions and 5 deletions
@@ -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