fix:修复错误信息双写导致响应异常

This commit is contained in:
qian committed 2026-07-16 21:45:59 +08:00
1 parent 88447ac558
commit ccecc512db
15 files changed
+358 -34

No files matched your search

+6 -3
View File
@@ -351,13 +351,16 @@ func (a *Adaptor) handleCodeAssistAggregatedStream(ctx context.Context, resp *ht
body = unwrapCodeAssistData(body)
clientFormat := info.GetOriginalClientFormat()
if clientFormat == constant.RelayFormatGemini {
// Gemini 原生格式:保留完整上游错误响应,标记以防上层二次写入
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(resp.StatusCode)
_, _ = writer.Write(body)
} else {
writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode)
err := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
err.ResponseWritten = true
return nil, err
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
// OpenAI 等格式:不写响应,交上层错误写入器统一写入
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
}
// 读取所有 SSE chunks 并聚合
+21 -20
View File
@@ -50,10 +50,13 @@ func (a *Adaptor) handleGeminiNativeNonStream(ctx context.Context, resp *http.Re
}
if resp.StatusCode != http.StatusOK {
// Gemini 原生格式透传:已写入完整上游错误响应,标记以防上层二次写入
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(resp.StatusCode)
_, _ = writer.Write(body)
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
err := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
err.ResponseWritten = true
return nil, err
}
writer.Header().Set("Content-Type", "application/json")
@@ -73,10 +76,13 @@ func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Respo
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
// Gemini 原生格式透传:已写入完整上游错误响应,标记以防上层二次写入
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(resp.StatusCode)
_, _ = writer.Write(body)
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
err := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
err.ResponseWritten = true
return nil, err
}
helper.SetEventStreamHeaders(writer)
@@ -163,8 +169,8 @@ func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Respon
}
if resp.StatusCode != http.StatusOK {
writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode)
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
// 不写响应:交上层 WriteRelayError 统一写入,避免双重写入与重试时的响应污染
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
}
var geminiResp dto.GeminiChatResponse
@@ -199,8 +205,8 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode)
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
// 不写响应:交上层 WriteRelayError 统一写入,避免双重写入与重试时的响应污染
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
}
helper.SetEventStreamHeaders(writer)
@@ -683,8 +689,10 @@ func geminiStatusToOpenAIType(status string) string {
}
}
// writeGeminiErrorAsOpenAI 将 Gemini 上游错误转换为 OpenAI 格式写入响应
func writeGeminiErrorAsOpenAI(writer http.ResponseWriter, body []byte, defaultStatusCode int) {
// buildGeminiUpstreamError 解析 Gemini 上游错误,构造携带正确 type 的 RelayError。
// 用于 OpenAI 出站路径:adaptor 不直接写响应,交上层错误写入器(WriteRelayError)统一写入一次,
// 既消除双重写入,又避免非流式可重试错误提前 WriteHeader 造成的重试响应污染。
func buildGeminiUpstreamError(body []byte, defaultStatusCode int) *constant.RelayError {
code, status, message := parseGeminiError(body)
if code == 0 {
code = defaultStatusCode
@@ -692,16 +700,9 @@ func writeGeminiErrorAsOpenAI(writer http.ResponseWriter, body []byte, defaultSt
if code < 100 || code > 599 {
code = 500
}
openaiErr, _ := json.Marshal(map[string]any{
"error": map[string]any{
"message": message,
"type": geminiStatusToOpenAIType(status),
"param": nil,
"code": nil,
},
})
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(code)
_, _ = writer.Write(openaiErr)
return &constant.RelayError{
StatusCode: code,
Message: message,
Type: geminiStatusToOpenAIType(status),
}
}
+41
View File
@@ -0,0 +1,41 @@
package gemini
import (
"strings"
"testing"
)
// TestBuildGeminiUpstreamError_ResourceExhausted 验证真实 Gemini 429(区域配额耗尽)
// 被解析为携带正确 type 的 RelayError,且 message 为简短文案而非整个 body。
// 回归保护:OpenAI 出站路径上游报错时,由上层写入器写出单条 rate_limit_error,
// 不再出现 adaptor 与上层各写一次的「双重写入」。
func TestBuildGeminiUpstreamError_ResourceExhausted(t *testing.T) {
body := []byte(`{
"error": {
"code": 429,
"message": "Quota exceeded for quota metric 'API requests' and limit 'Request limit per minute for a region' of service 'generativelanguage.googleapis.com' for consumer 'project_number:121235835710'.",
"status": "RESOURCE_EXHAUSTED"
}
}`)
err := buildGeminiUpstreamError(body, 200)
if err.StatusCode != 429 {
t.Errorf("StatusCode = %d, want 429", err.StatusCode)
}
if err.Type != "rate_limit_error" {
t.Errorf("Type = %q, want rate_limit_error", err.Type)
}
if !strings.HasPrefix(err.Message, "Quota exceeded") {
t.Errorf("Message = %q, want short upstream message (not the whole body)", err.Message)
}
}
// TestBuildGeminiUpstreamError_FallbackStatusCode 验证 body 无法解析出 code 时回退到默认状态码
func TestBuildGeminiUpstreamError_FallbackStatusCode(t *testing.T) {
// 非 JSON body:parseGeminiError 返回 code=0,应回退到 defaultStatusCode
err := buildGeminiUpstreamError([]byte("plain text error"), 503)
if err.StatusCode != 503 {
t.Errorf("StatusCode = %d, want 503 (fallback)", err.StatusCode)
}
}
+9 -3
View File
@@ -304,7 +304,9 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re
// 尝试解析上游错误格式,如果是标准格式则透传原始响应
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
@@ -341,7 +343,9 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
// 尝试透传上游错误格式
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
@@ -465,7 +469,9 @@ func (a *Adaptor) handleModerationResponse(_ context.Context, resp *http.Respons
}
if resp.StatusCode != http.StatusOK {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
+6 -2
View File
@@ -21,7 +21,9 @@ func handleAudioSpeechResponse(ctx context.Context, resp *http.Response, info *c
body, _ := io.ReadAll(resp.Body)
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
@@ -67,7 +69,9 @@ func handleAudioTranscriptionResponse(ctx context.Context, resp *http.Response,
if resp.StatusCode != http.StatusOK {
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
+6 -2
View File
@@ -34,7 +34,9 @@ func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info
"error": map[string]any{"type": "api_error", "message": string(body)},
})
_, _ = writer.Write(claudeErr)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
var openaiResp dto.ChatCompletionResponse
@@ -74,7 +76,9 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c
"error": map[string]any{"type": "api_error", "message": string(body)},
})
_, _ = writer.Write(claudeErr)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
helper.SetEventStreamHeaders(writer)
+3 -1
View File
@@ -24,7 +24,9 @@ func (a *Adaptor) handleCompletionNonStreamResponse(ctx context.Context, resp *h
if resp.StatusCode != http.StatusOK {
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
+6 -2
View File
@@ -27,7 +27,9 @@ func handleGeminiInboundNonStream(ctx context.Context, resp *http.Response, info
if resp.StatusCode != http.StatusOK {
writeOpenAIErrorAsGemini(writer, body, resp.StatusCode)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
var openaiResp dto.ChatCompletionResponse
@@ -59,7 +61,9 @@ func handleGeminiInboundStream(ctx context.Context, resp *http.Response, info *c
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
writeOpenAIErrorAsGemini(writer, body, resp.StatusCode)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
helper.SetEventStreamHeaders(writer)
+3 -1
View File
@@ -26,7 +26,9 @@ func handleRerankResponse(ctx context.Context, resp *http.Response, info *common
if resp.StatusCode != http.StatusOK {
if isUpstreamOpenAIError(body) {
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil)
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil)
}
+4
View File
@@ -15,6 +15,10 @@ type RelayError struct {
Message string
Type string // upstream_error / channel_error / auth_error / request_error
Cause error
// ResponseWritten 表示 adaptor 已直接向客户端写入响应体,
// 上层错误写入器(WriteRelayError / WriteClaudeRelayError / WriteGeminiRelayError)应跳过二次写入。
// 字段对重试 / 健康度 / 计费 / 状态码重映射等逻辑透明(仍按 StatusCode 判断)。
ResponseWritten bool
}
func (e *RelayError) Error() string {
+6
View File
@@ -145,6 +145,12 @@ func WriteRelayError(w http.ResponseWriter, err error) {
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
if errors.As(err, &prewritten) && prewritten.ResponseWritten {
return
}
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
+6
View File
@@ -25,6 +25,12 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) {
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
if errors.As(err, &prewritten) && prewritten.ResponseWritten {
return
}
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/qianfree/team-api/relay/constant"
)
// TestErrorWriters_SkipResponseWritten 验证当 adaptor 已置 ResponseWritten=true 时,
// 三个错误写入器都跳过二次写入(不写 header、不写 body)。
// 这是 Gemini/OpenAI「双重写入」修复的核心机制兜底。
func TestErrorWriters_SkipResponseWritten(t *testing.T) {
writtenErr := constant.NewUpstreamError(http.StatusTooManyRequests, "upstream 429", nil)
writtenErr.ResponseWritten = true
cases := []struct {
name string
fn func(http.ResponseWriter, error)
}{
{"WriteRelayError", WriteRelayError},
{"WriteClaudeRelayError", WriteClaudeRelayError},
{"WriteGeminiRelayError", WriteGeminiRelayError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c.fn(rec, writtenErr)
if rec.Code != http.StatusOK {
t.Errorf("Code = %d, want 200 (no WriteHeader should happen)", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("body = %q, want empty (no write should happen)", rec.Body.String())
}
})
}
}
// TestErrorWriters_WriteNormalError 验证普通(未标记 ResponseWritten)的 RelayError
// 仍被三个写入器正常写入一次,确保短路逻辑不影响正常错误路径。
func TestErrorWriters_WriteNormalError(t *testing.T) {
normalErr := constant.NewUpstreamError(http.StatusTooManyRequests, "upstream 429", nil)
cases := []struct {
name string
fn func(http.ResponseWriter, error)
}{
{"WriteRelayError", WriteRelayError},
{"WriteClaudeRelayError", WriteClaudeRelayError},
{"WriteGeminiRelayError", WriteGeminiRelayError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c.fn(rec, normalErr)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("Code = %d, want 429", rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("body empty, want non-empty error response")
}
})
}
}
+6
View File
@@ -61,6 +61,12 @@ func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName stri
// WriteGeminiRelayError 写入 Gemini 格式的错误响应
func WriteGeminiRelayError(w http.ResponseWriter, err error) {
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
if errors.As(err, &prewritten) && prewritten.ResponseWritten {
return
}
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
// WebhookEvent 接收到的 webhook 事件格式
type WebhookEvent struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID int64 `json:"tenant_id"`
Timestamp string `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
// 全局变量:webhook 密钥,用于验签
var secretKey string
func main() {
// 从命令行参数或环境变量获取密钥
secretKey = os.Getenv("WEBHOOK_SECRET")
if len(os.Args) > 1 {
secretKey = os.Args[1]
}
if secretKey == "" {
log.Println("⚠️ 未提供 webhook 密钥,签名验证将跳过")
log.Println(" 用法: go run main.go <secret_key>")
log.Println(" 或设置环境变量: WEBHOOK_SECRET=whk_xxx go run main.go")
log.Println()
} else {
log.Printf("🔑 已加载密钥: %s...%s\n", secretKey[:8], secretKey[len(secretKey)-4:])
}
port := os.Getenv("PORT")
if port == "" {
port = "9090"
}
http.HandleFunc("/webhook", handleWebhook)
http.HandleFunc("/health", handleHealth)
http.HandleFunc("/", handleNotFound)
addr := ":" + port
log.Printf("🚀 Webhook 接收服务启动在 http://localhost%s/webhook\n", addr)
log.Printf(" 健康检查: http://localhost%s/health\n", addr)
log.Printf(" 等待接收 webhook 事件...\n")
log.Println(strings.Repeat("=", 70))
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func handleNotFound(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "not_found",
"message": "请使用 POST /webhook 接收事件",
})
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method_not_allowed"}`, http.StatusMethodNotAllowed)
return
}
now := time.Now()
receiveTime := now.Format("2006-01-02 15:04:05.000")
// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ [%s] 读取请求体失败: %v\n", receiveTime, err)
http.Error(w, `{"error":"read_body_failed"}`, http.StatusBadRequest)
return
}
defer r.Body.Close()
// 提取关键 Header
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
eventType := r.Header.Get("X-Webhook-Event")
eventID := r.Header.Get("X-Webhook-ID")
log.Printf("📩 [%s] 收到 Webhook 事件", receiveTime)
log.Println(strings.Repeat("-", 50))
log.Printf(" Event ID: %s", eventID)
log.Printf(" Event Type: %s", eventType)
log.Printf(" Timestamp: %s", timestamp)
// 签名验证
if secretKey != "" {
expectedSig := computeSignature(secretKey, timestamp, body)
if hmac.Equal([]byte(signature), []byte(expectedSig)) {
log.Printf(" 签名验证: ✅ 通过")
} else {
log.Printf(" 签名验证: ❌ 失败!")
log.Printf(" 期望签名: %s", expectedSig)
log.Printf(" 实际签名: %s", signature)
}
} else {
log.Printf(" 签名验证: ⏭️ 跳过(未配置密钥)")
}
// 解析 Payload
var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
log.Printf(" JSON 解析: ❌ 失败: %v", err)
log.Printf(" 原始 Body: %s", string(body))
} else {
log.Printf(" Tenant ID: %d", event.TenantID)
log.Printf(" Event Time: %s", event.Timestamp)
// 格式化输出 Data
if event.Data != nil {
dataJSON, _ := json.MarshalIndent(event.Data, " ", " ")
log.Printf(" Data:")
log.Printf(" %s", string(dataJSON))
}
}
// 原始 Body(美化的 JSON)
var prettyBody map[string]interface{}
if err := json.Unmarshal(body, &prettyBody); err == nil {
beautified, _ := json.MarshalIndent(prettyBody, " ", " ")
log.Printf(" 完整 Payload:")
log.Printf(" %s", string(beautified))
} else {
log.Printf(" 原始 Body: %s", string(body))
}
log.Println(strings.Repeat("-", 50))
log.Printf(" ✅ 已处理 (%d bytes)", len(body))
log.Println(strings.Repeat("=", 70))
// 返回 200 OK
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "received",
"event_id": eventID,
})
}
// computeSignature 计算 HMAC-SHA256 签名,与系统实现一致
// 签名内容: timestamp + "." + body
func computeSignature(secret, timestamp string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "." + string(body)))
return hex.EncodeToString(mac.Sum(nil))
}