From 7468427e447a5264640e7ebcc23907af476cedf8 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 15:49:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(messages):=20/v1/messages=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E5=B1=82=E9=94=99=E8=AF=AF=E5=AF=B9=E9=BD=90=20failov?= =?UTF-8?q?er=20=E9=93=BE=E8=B7=AF=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=20502?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3850 (part 2) --- .../service/openai_gateway_messages.go | 13 +-- ...ateway_messages_transport_failover_test.go | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/openai_gateway_messages_transport_failover_test.go diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 8c5c801944..92ee773166 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -302,18 +302,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_gateway_messages_transport_failover_test.go b/backend/internal/service/openai_gateway_messages_transport_failover_test.go new file mode 100644 index 0000000000..a61da140f5 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_transport_failover_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestForwardAsAnthropic_TransportError_ReturnsFailoverError(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: errors.New(`dial tcp 1.2.3.4:443: connect: connection refused`), + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "transport error should return UpstreamFailoverError for handler failover, got: %T", err) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) +} + +func TestForwardAsAnthropic_TransportError_DoesNotWriteResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: errors.New(`read tcp: connection reset by peer`), + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Equal(t, http.StatusOK, rec.Code, "transport error must not write HTTP response — handler owns the response for failover") + require.Empty(t, rec.Body.String(), "response body must be empty so handler can write the correct error or failover") +} + +func TestForwardAsAnthropic_TransportError_ClientCanceled_NoFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)).WithContext(cancelCtx) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + err: context.Canceled, + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + account := rawChatCompletionsTestAccount() + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "client-canceled transport error should NOT trigger failover") +}