From 65fa728921f8de8d177c020143f8ead8599b032f Mon Sep 17 00:00:00 2001 From: hsn Date: Tue, 23 Jun 2026 22:53:20 +0800 Subject: [PATCH] fix(openai): fail over on chat transport errors --- .../openai_gateway_chat_completions.go | 13 +---- .../openai_gateway_chat_completions_raw.go | 13 +---- ...ai_upstream_transport_error_handle_test.go | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a1051afe5e..d69b1114c3 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -241,18 +241,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } 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, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_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_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index d03c7b6636..eef980128b 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -171,18 +171,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } 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, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_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_upstream_transport_error_handle_test.go b/backend/internal/service/openai_upstream_transport_error_handle_test.go index 35bd56a194..3ea8b43181 100644 --- a/backend/internal/service/openai_upstream_transport_error_handle_test.go +++ b/backend/internal/service/openai_upstream_transport_error_handle_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -37,6 +39,21 @@ func newOpenAITransportErrTestContext() (*gin.Context, *httptest.ResponseRecorde return c, rec } +type failingOpenAIHTTPUpstream struct { + err error + calls int +} + +func (u *failingOpenAIHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.calls++ + return nil, u.err +} + +func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + u.calls++ + return nil, u.err +} + // A durable proxy/credential failure must (a) temporarily unschedule the account // so it stops being hammered, and (b) return a failover error so the handler // switches to a healthy account instead of writing a hard 502 itself. @@ -159,3 +176,37 @@ func TestHandleOpenAIUpstreamTransportError_DeadlineExceeded_StillFailsOver(t *t var fo *UpstreamFailoverError require.True(t, errors.As(err, &fo), "context.DeadlineExceeded must still return *UpstreamFailoverError") } + +func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) { + repo := &openaiTransportAccountRepoStub{} + upstream := &failingOpenAIHTTPUpstream{ + err: errors.New(`Post "https://opencode.ai/zen/v1/chat/completions": EOF`), + } + svc := &OpenAIGatewayService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + } + account := &Account{ + ID: 81, + Name: "oc-20053", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://opencode.ai/zen/v1"}, + } + c, rec := newOpenAITransportErrTestContext() + body := []byte(`{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"hello"}]}`) + + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + + require.Equal(t, 1, upstream.calls) + var fo *UpstreamFailoverError + require.True(t, errors.As(err, &fo), "transport error must trigger account failover") + require.Equal(t, http.StatusBadGateway, fo.StatusCode) + require.Empty(t, repo.tempUnschedCalls, "plain EOF is transient: fail over but do not evict") + require.Equal(t, 0, rec.Body.Len(), "service must not write a hard 502 before handler can fail over") +}