Skip to content

Commit e5050a4

Browse files
committed
fix(account-share): 瞬时 429 保留房间绑定 + 错误契约细分与脱敏
- 共享号池 30s 内可自愈的限流只返回可重试错误,保留 active membership/binding; 仅当超出完整宽限窗口且存在健康账号可替换时才做持久化重排队 - SetRateLimited/SetRateLimitedIfLater 改为原生 SQL:保留本轮首次限流时间, 重置时刻只延长不回退,为短限流判定提供正确时间基准 - 新增 classifyAccountShareModeHTTPError 单点分类器,统一 OpenAI/Anthropic/ chat-completions/responses/WebSocket 五条通道:恢复中返回 503+Retry-After, 空闲超时返回 409,不再误报为「未绑定」 - 错误响应仅携带重试秒数,不再泄露账号 ID、membership ID、upstream 等内部信息 - 管理员变更在用账号的强制确认新增 expected_versions 房间版本快照, 确认期间房间状态变化时返回 VERSION_CONFLICT 并提示刷新 - 账号新建/编辑的模型白名单候选改为从渠道定价目录加载,补齐加载中/失败/为空 三态提示;凭证粘贴入口提交前等待目录就绪,避免写入已下架模型 未涉及数据库结构变更。
1 parent 276f652 commit e5050a4

27 files changed

Lines changed: 3081 additions & 502 deletions
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package handler
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/Wei-Shaw/sub2api/internal/service"
11+
coderws "github.com/coder/websocket"
12+
"github.com/gin-gonic/gin"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestClassifyAccountShareModeHTTPErrorPreservesSpecificContract(t *testing.T) {
17+
wrapSelection := func(err error) error {
18+
return fmt.Errorf("%w: %w", service.ErrAccountShareModeSelection, err)
19+
}
20+
recovering := service.NewAccountShareModeRecoveringError(17)
21+
22+
tests := []struct {
23+
name string
24+
err error
25+
status int
26+
openAIType string
27+
anthropicType string
28+
message string
29+
retryAfter int
30+
}{
31+
{
32+
name: "plain true unbound",
33+
err: service.ErrAccountShareModeGroupUnbound,
34+
status: http.StatusBadRequest,
35+
openAIType: "account_share_mode_unbound",
36+
anthropicType: "invalid_request_error",
37+
message: "该分组未绑定账号",
38+
},
39+
{
40+
name: "wrapped true unbound wins over broad selection",
41+
err: wrapSelection(service.ErrAccountShareModeGroupUnbound),
42+
status: http.StatusBadRequest,
43+
openAIType: "account_share_mode_unbound",
44+
anthropicType: "invalid_request_error",
45+
message: "该分组未绑定账号",
46+
},
47+
{
48+
name: "plain recovering exposes safe retry after",
49+
err: recovering,
50+
status: http.StatusServiceUnavailable,
51+
openAIType: "account_share_recovering",
52+
anthropicType: "api_error",
53+
message: "共享账号正在恢复,请稍后重试",
54+
retryAfter: 17,
55+
},
56+
{
57+
name: "wrapped recovering wins over broad selection",
58+
err: wrapSelection(recovering),
59+
status: http.StatusServiceUnavailable,
60+
openAIType: "account_share_recovering",
61+
anthropicType: "api_error",
62+
message: "共享账号正在恢复,请稍后重试",
63+
retryAfter: 17,
64+
},
65+
{
66+
name: "idle timeout requires rejoin",
67+
err: service.ErrAccountShareMembershipIdleTimeout,
68+
status: http.StatusConflict,
69+
openAIType: "account_share_idle_timeout",
70+
anthropicType: "invalid_request_error",
71+
message: "账号房间绑定已因空闲超时结束,请重新加入房间",
72+
},
73+
}
74+
75+
for _, test := range tests {
76+
t.Run(test.name, func(t *testing.T) {
77+
details, handled := classifyAccountShareModeHTTPError(test.err)
78+
79+
require.True(t, handled)
80+
require.Equal(t, test.status, details.status)
81+
require.Equal(t, test.openAIType, details.openAIType)
82+
require.Equal(t, test.anthropicType, details.anthropicType)
83+
require.Equal(t, test.message, details.message)
84+
require.Equal(t, test.retryAfter, details.retryAfter)
85+
})
86+
}
87+
}
88+
89+
func TestAccountShareModeWSCloseDetailsSeparatesUnboundFromRecovering(t *testing.T) {
90+
wrapSelection := func(err error) error {
91+
return fmt.Errorf("%w: %w", service.ErrAccountShareModeSelection, err)
92+
}
93+
tests := []struct {
94+
name string
95+
err error
96+
status coderws.StatusCode
97+
reason string
98+
}{
99+
{
100+
name: "true unbound is a policy violation",
101+
err: wrapSelection(service.ErrAccountShareModeGroupUnbound),
102+
status: coderws.StatusPolicyViolation,
103+
reason: "该分组未绑定账号",
104+
},
105+
{
106+
name: "recovering asks the client to retry later",
107+
err: wrapSelection(service.NewAccountShareModeRecoveringError(11)),
108+
status: coderws.StatusTryAgainLater,
109+
reason: "共享账号正在恢复,请稍后重试",
110+
},
111+
{
112+
name: "unsupported model is a policy violation",
113+
err: wrapSelection(service.ErrAccountShareModeUnsupportedModel),
114+
status: coderws.StatusPolicyViolation,
115+
reason: "模型不支持",
116+
},
117+
}
118+
119+
for _, test := range tests {
120+
t.Run(test.name, func(t *testing.T) {
121+
status, reason, handled := accountShareModeWSCloseDetails(test.err)
122+
123+
require.True(t, handled)
124+
require.Equal(t, test.status, status)
125+
require.Equal(t, test.reason, reason)
126+
})
127+
}
128+
}
129+
130+
func TestHandleAccountShareModeSelectionErrorWritesSafeHTTPContract(t *testing.T) {
131+
gin.SetMode(gin.TestMode)
132+
handler := &OpenAIGatewayHandler{}
133+
134+
tests := []struct {
135+
name string
136+
err error
137+
status int
138+
retryAfter string
139+
errType string
140+
message string
141+
}{
142+
{
143+
name: "recovering",
144+
err: fmt.Errorf("account_id=797016 membership_id=73814 upstream=secret: %w", service.NewAccountShareModeRecoveringError(17)),
145+
status: http.StatusServiceUnavailable,
146+
retryAfter: "17",
147+
errType: "account_share_recovering",
148+
message: "共享账号正在恢复,请稍后重试",
149+
},
150+
{
151+
name: "true unbound",
152+
err: fmt.Errorf("internal binding detail: %w", service.ErrAccountShareModeGroupUnbound),
153+
status: http.StatusBadRequest,
154+
errType: "account_share_mode_unbound",
155+
message: "该分组未绑定账号",
156+
},
157+
{
158+
name: "idle timeout",
159+
err: fmt.Errorf("membership_id=73814: %w", service.ErrAccountShareMembershipIdleTimeout),
160+
status: http.StatusConflict,
161+
errType: "account_share_idle_timeout",
162+
message: "账号房间绑定已因空闲超时结束,请重新加入房间",
163+
},
164+
}
165+
166+
for _, test := range tests {
167+
t.Run(test.name, func(t *testing.T) {
168+
recorder := httptest.NewRecorder()
169+
ctx, _ := gin.CreateTestContext(recorder)
170+
171+
require.True(t, handler.handleAccountShareModeSelectionError(ctx, test.err, false))
172+
require.Equal(t, test.status, recorder.Code)
173+
require.Equal(t, test.retryAfter, recorder.Header().Get("Retry-After"))
174+
require.JSONEq(t, fmt.Sprintf(
175+
`{"error":{"type":%q,"message":%q}}`,
176+
test.errType,
177+
test.message,
178+
), recorder.Body.String())
179+
for _, secret := range []string{"797016", "73814", "upstream=secret", "internal binding detail"} {
180+
require.NotContains(t, recorder.Body.String(), secret)
181+
}
182+
})
183+
}
184+
}
185+
186+
func TestHandleAccountShareModeAnthropicErrorWritesSafeHTTPContract(t *testing.T) {
187+
gin.SetMode(gin.TestMode)
188+
recorder := httptest.NewRecorder()
189+
ctx, _ := gin.CreateTestContext(recorder)
190+
handler := &OpenAIGatewayHandler{}
191+
err := fmt.Errorf("account_id=797016 upstream=secret: %w", service.NewAccountShareModeRecoveringError(23))
192+
193+
require.True(t, handler.handleAccountShareModeAnthropicError(ctx, err, false))
194+
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
195+
require.Equal(t, "23", recorder.Header().Get("Retry-After"))
196+
require.JSONEq(t,
197+
`{"type":"error","error":{"type":"api_error","message":"共享账号正在恢复,请稍后重试"}}`,
198+
recorder.Body.String(),
199+
)
200+
require.NotContains(t, recorder.Body.String(), "797016")
201+
require.NotContains(t, recorder.Body.String(), "upstream=secret")
202+
}
203+
204+
func TestAccountShareModeWSCloseReasonDoesNotExposeCause(t *testing.T) {
205+
cause := fmt.Errorf("account_id=797016 membership_id=73814 upstream=secret: %w", service.NewAccountShareModeRecoveringError(11))
206+
status, reason, handled := accountShareModeWSCloseDetails(cause)
207+
208+
require.True(t, handled)
209+
require.Equal(t, coderws.StatusTryAgainLater, status)
210+
require.Equal(t, "共享账号正在恢复,请稍后重试", reason)
211+
for _, secret := range []string{"797016", "73814", "upstream=secret"} {
212+
require.False(t, strings.Contains(reason, secret))
213+
}
214+
}

backend/internal/handler/gateway_handler.go

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,25 +2012,13 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
20122012
}
20132013

20142014
func (h *GatewayHandler) handleAccountShareModeAnthropicError(c *gin.Context, err error, streamStarted bool) bool {
2015-
switch {
2016-
case errors.Is(err, service.ErrAccountShareModeGroupUnbound):
2017-
h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "该分组未绑定账号", streamStarted)
2018-
return true
2019-
case errors.Is(err, service.ErrAccountShareBalanceBelowMinimum):
2020-
h.handleStreamingAwareError(c, http.StatusForbidden, "permission_error", "账户余额低于共享账号最低准入余额", streamStarted)
2021-
return true
2022-
case errors.Is(err, service.ErrAccountSharePerUserConcurrencyExceeded):
2023-
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "共享账号单用户并发已达上限", streamStarted)
2024-
return true
2025-
case errors.Is(err, service.ErrAccountShareModeUnsupportedModel):
2026-
h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "模型不支持", streamStarted)
2027-
return true
2028-
case errors.Is(err, service.ErrAccountShareModeSelection):
2029-
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "共享账号暂时不可用,请稍后重试", streamStarted)
2030-
return true
2031-
default:
2015+
details, ok := classifyAccountShareModeHTTPError(err)
2016+
if !ok {
20322017
return false
20332018
}
2019+
applyAccountShareModeRetryAfter(c, details)
2020+
h.handleStreamingAwareError(c, details.status, details.anthropicType, details.message, streamStarted)
2021+
return true
20342022
}
20352023

20362024
// ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。

backend/internal/handler/gateway_handler_chat_completions.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ routeLoop:
184184
selectionCtx := openAIAccountShareModeRequestContext(c, currentAPIKey)
185185
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(selectionCtx, currentAPIKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
186186
if err != nil {
187-
if errors.Is(err, service.ErrAccountShareModeSelection) {
188-
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "account_share_unavailable", "共享账号暂时不可用,请稍后重试")
187+
if details, handled := classifyAccountShareModeHTTPError(err); handled {
188+
applyAccountShareModeRetryAfter(c, details)
189+
h.chatCompletionsErrorResponse(c, details.status, details.openAIType, details.message)
189190
return
190191
}
191192
if len(fs.FailedAccountIDs) == 0 {

backend/internal/handler/gateway_handler_responses.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ routeLoop:
184184
selectionCtx := openAIAccountShareModeRequestContext(c, currentAPIKey)
185185
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(selectionCtx, currentAPIKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
186186
if err != nil {
187-
if errors.Is(err, service.ErrAccountShareModeSelection) {
188-
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "account_share_unavailable", "共享账号暂时不可用,请稍后重试")
187+
if details, handled := classifyAccountShareModeHTTPError(err); handled {
188+
applyAccountShareModeRetryAfter(c, details)
189+
h.responsesErrorResponse(c, details.status, details.openAIType, details.message)
189190
return
190191
}
191192
if len(fs.FailedAccountIDs) == 0 {

0 commit comments

Comments
 (0)