Skip to content

Commit 944b7f7

Browse files
authored
Merge pull request Wei-Shaw#904 from james-6-23/fix-pool-mode-retry
fix: OpenAI临时性400错误支持池模式同账号重试 & HelpTooltip层级修复
2 parents 53825eb + 5fa22fd commit 944b7f7

4 files changed

Lines changed: 160 additions & 25 deletions

File tree

backend/internal/service/openai_gateway_messages.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service
22

33
import (
44
"bufio"
5+
"bytes"
56
"context"
67
"encoding/json"
78
"errors"
@@ -140,12 +141,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
140141

141142
// 8. Handle error response with failover
142143
if resp.StatusCode >= 400 {
143-
if s.shouldFailoverUpstreamError(resp.StatusCode) {
144-
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
145-
_ = resp.Body.Close()
144+
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
145+
_ = resp.Body.Close()
146+
resp.Body = io.NopCloser(bytes.NewReader(respBody))
146147

147-
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
148-
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
148+
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
149+
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
150+
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
149151
upstreamDetail := ""
150152
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
151153
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
@@ -167,7 +169,11 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
167169
if s.rateLimitService != nil {
168170
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
169171
}
170-
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
172+
return nil, &UpstreamFailoverError{
173+
StatusCode: resp.StatusCode,
174+
ResponseBody: respBody,
175+
RetryableOnSameAccount: account.IsPoolMode() && isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody),
176+
}
171177
}
172178
// Non-failover error: return Anthropic-formatted error to client
173179
return s.handleAnthropicErrorResponse(resp, c, account)

backend/internal/service/openai_gateway_service.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,36 @@ func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg strin
911911
return false
912912
}
913913

914+
func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
915+
if upstreamStatusCode != http.StatusBadRequest {
916+
return false
917+
}
918+
919+
match := func(text string) bool {
920+
lower := strings.ToLower(strings.TrimSpace(text))
921+
if lower == "" {
922+
return false
923+
}
924+
if strings.Contains(lower, "an error occurred while processing your request") {
925+
return true
926+
}
927+
return strings.Contains(lower, "you can retry your request") &&
928+
strings.Contains(lower, "help.openai.com") &&
929+
strings.Contains(lower, "request id")
930+
}
931+
932+
if match(upstreamMsg) {
933+
return true
934+
}
935+
if len(upstreamBody) == 0 {
936+
return false
937+
}
938+
if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
939+
return true
940+
}
941+
return match(string(upstreamBody))
942+
}
943+
914944
// ExtractSessionID extracts the raw session ID from headers or body without hashing.
915945
// Used by ForwardAsAnthropic to pass as prompt_cache_key for upstream cache.
916946
func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) string {
@@ -1518,6 +1548,13 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool
15181548
}
15191549
}
15201550

1551+
func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
1552+
if s.shouldFailoverUpstreamError(statusCode) {
1553+
return true
1554+
}
1555+
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
1556+
}
1557+
15211558
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
15221559
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
15231560
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
@@ -2016,13 +2053,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
20162053

20172054
// Handle error response
20182055
if resp.StatusCode >= 400 {
2019-
if s.shouldFailoverUpstreamError(resp.StatusCode) {
2020-
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
2021-
_ = resp.Body.Close()
2022-
resp.Body = io.NopCloser(bytes.NewReader(respBody))
2056+
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
2057+
_ = resp.Body.Close()
2058+
resp.Body = io.NopCloser(bytes.NewReader(respBody))
20232059

2024-
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
2025-
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
2060+
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
2061+
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
2062+
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
20262063
upstreamDetail := ""
20272064
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
20282065
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
@@ -2046,7 +2083,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
20462083
return nil, &UpstreamFailoverError{
20472084
StatusCode: resp.StatusCode,
20482085
ResponseBody: respBody,
2049-
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
2086+
RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
20502087
}
20512088
}
20522089
return s.handleErrorResponse(ctx, resp, c, account, body)

backend/internal/service/openai_gateway_service_codex_cli_only_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,26 @@ func TestLogOpenAIInstructionsRequiredDebug_NonTargetErrorSkipped(t *testing.T)
211211
require.False(t, logSink.ContainsMessage("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查"))
212212
}
213213

214+
func TestIsOpenAITransientProcessingError(t *testing.T) {
215+
require.True(t, isOpenAITransientProcessingError(
216+
http.StatusBadRequest,
217+
"An error occurred while processing your request.",
218+
nil,
219+
))
220+
221+
require.True(t, isOpenAITransientProcessingError(
222+
http.StatusBadRequest,
223+
"",
224+
[]byte(`{"error":{"message":"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_123 in your message."}}`),
225+
))
226+
227+
require.False(t, isOpenAITransientProcessingError(
228+
http.StatusBadRequest,
229+
"Missing required parameter: 'instructions'",
230+
[]byte(`{"error":{"message":"Missing required parameter: 'instructions'"}}`),
231+
))
232+
}
233+
214234
func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) {
215235
gin.SetMode(gin.TestMode)
216236
logSink, restore := captureStructuredLog(t)
@@ -264,3 +284,51 @@ func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing
264284
require.True(t, logSink.ContainsField("request_body_size"))
265285
require.False(t, logSink.ContainsField("request_body_preview"))
266286
}
287+
288+
func TestOpenAIGatewayService_Forward_TransientProcessingErrorTriggersFailover(t *testing.T) {
289+
gin.SetMode(gin.TestMode)
290+
291+
rec := httptest.NewRecorder()
292+
c, _ := gin.CreateTestContext(rec)
293+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
294+
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
295+
c.Request.Header.Set("Content-Type", "application/json")
296+
297+
upstream := &httpUpstreamRecorder{
298+
resp: &http.Response{
299+
StatusCode: http.StatusBadRequest,
300+
Header: http.Header{
301+
"Content-Type": []string{"application/json"},
302+
"x-request-id": []string{"rid-processing-400"},
303+
},
304+
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_123 in your message.","type":"invalid_request_error"}}`)),
305+
},
306+
}
307+
svc := &OpenAIGatewayService{
308+
cfg: &config.Config{
309+
Gateway: config.GatewayConfig{ForceCodexCLI: false},
310+
},
311+
httpUpstream: upstream,
312+
}
313+
account := &Account{
314+
ID: 1001,
315+
Name: "codex max套餐",
316+
Platform: PlatformOpenAI,
317+
Type: AccountTypeAPIKey,
318+
Concurrency: 1,
319+
Credentials: map[string]any{"api_key": "sk-test"},
320+
Status: StatusActive,
321+
Schedulable: true,
322+
RateMultiplier: f64p(1),
323+
}
324+
body := []byte(`{"model":"gpt-5.1-codex","stream":false,"input":[{"type":"text","text":"hello"}]}`)
325+
326+
_, err := svc.Forward(context.Background(), c, account, body)
327+
require.Error(t, err)
328+
329+
var failoverErr *UpstreamFailoverError
330+
require.ErrorAs(t, err, &failoverErr)
331+
require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode)
332+
require.Contains(t, string(failoverErr.ResponseBody), "An error occurred while processing your request")
333+
require.False(t, c.Writer.Written(), "service 层应返回 failover 错误给上层换号,而不是直接向客户端写响应")
334+
}

frontend/src/components/common/HelpTooltip.vue

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue'
2+
import { ref, useTemplateRef, nextTick } from 'vue'
33
44
defineProps<{
55
content?: string
66
}>()
77
88
const show = ref(false)
9+
const triggerRef = useTemplateRef<HTMLElement>('trigger')
10+
const tooltipStyle = ref({ top: '0px', left: '0px' })
11+
12+
function onEnter() {
13+
show.value = true
14+
nextTick(updatePosition)
15+
}
16+
17+
function onLeave() {
18+
show.value = false
19+
}
20+
21+
function updatePosition() {
22+
const el = triggerRef.value
23+
if (!el) return
24+
const rect = el.getBoundingClientRect()
25+
tooltipStyle.value = {
26+
top: `${rect.top + window.scrollY}px`,
27+
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
28+
}
29+
}
930
</script>
1031

1132
<template>
1233
<div
34+
ref="trigger"
1335
class="group relative ml-1 inline-flex items-center align-middle"
14-
@mouseenter="show = true"
15-
@mouseleave="show = false"
36+
@mouseenter="onEnter"
37+
@mouseleave="onLeave"
1638
>
1739
<!-- Trigger Icon -->
1840
<slot name="trigger">
@@ -31,14 +53,16 @@ const show = ref(false)
3153
</svg>
3254
</slot>
3355

34-
<!-- Popover Content -->
35-
<div
36-
v-show="show"
37-
class="absolute bottom-full left-1/2 z-50 mb-2 w-64 -translate-x-1/2 rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:bg-gray-800"
38-
>
39-
<slot>{{ content }}</slot>
40-
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
41-
</div>
56+
<!-- Teleport to body to escape modal overflow clipping -->
57+
<Teleport to="body">
58+
<div
59+
v-show="show"
60+
class="fixed z-[99999] w-64 -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800"
61+
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
62+
>
63+
<slot>{{ content }}</slot>
64+
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
65+
</div>
66+
</Teleport>
4267
</div>
4368
</template>
44-

0 commit comments

Comments
 (0)