Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
11cf23da
Commit
11cf23da
authored
Apr 23, 2026
by
wx-11
Browse files
修改403逻辑: 先临时冷却,再根据连续次数决定是否判坏号
parent
eea6f388
Changes
11
Hide whitespace changes
Inline
Side-by-side
backend/cmd/server/wire_gen.go
View file @
11cf23da
...
@@ -124,9 +124,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
...
@@ -124,9 +124,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiQuotaService
:=
service
.
NewGeminiQuotaService
(
configConfig
,
settingRepository
)
geminiQuotaService
:=
service
.
NewGeminiQuotaService
(
configConfig
,
settingRepository
)
tempUnschedCache
:=
repository
.
NewTempUnschedCache
(
redisClient
)
tempUnschedCache
:=
repository
.
NewTempUnschedCache
(
redisClient
)
timeoutCounterCache
:=
repository
.
NewTimeoutCounterCache
(
redisClient
)
timeoutCounterCache
:=
repository
.
NewTimeoutCounterCache
(
redisClient
)
openAI403CounterCache
:=
repository
.
NewOpenAI403CounterCache
(
redisClient
)
geminiTokenCache
:=
repository
.
NewGeminiTokenCache
(
redisClient
)
geminiTokenCache
:=
repository
.
NewGeminiTokenCache
(
redisClient
)
compositeTokenCacheInvalidator
:=
service
.
NewCompositeTokenCacheInvalidator
(
geminiTokenCache
)
compositeTokenCacheInvalidator
:=
service
.
NewCompositeTokenCacheInvalidator
(
geminiTokenCache
)
rateLimitService
:=
service
.
ProvideRateLimitService
(
accountRepository
,
usageLogRepository
,
configConfig
,
geminiQuotaService
,
tempUnschedCache
,
timeoutCounterCache
,
settingService
,
compositeTokenCacheInvalidator
)
rateLimitService
:=
service
.
ProvideRateLimitService
(
accountRepository
,
usageLogRepository
,
configConfig
,
geminiQuotaService
,
tempUnschedCache
,
timeoutCounterCache
,
openAI403CounterCache
,
settingService
,
compositeTokenCacheInvalidator
)
httpUpstream
:=
repository
.
NewHTTPUpstream
(
configConfig
)
httpUpstream
:=
repository
.
NewHTTPUpstream
(
configConfig
)
claudeUsageFetcher
:=
repository
.
NewClaudeUsageFetcher
(
httpUpstream
)
claudeUsageFetcher
:=
repository
.
NewClaudeUsageFetcher
(
httpUpstream
)
antigravityQuotaFetcher
:=
service
.
NewAntigravityQuotaFetcher
(
proxyRepository
)
antigravityQuotaFetcher
:=
service
.
NewAntigravityQuotaFetcher
(
proxyRepository
)
...
...
backend/internal/repository/openai_403_counter_cache.go
0 → 100644
View file @
11cf23da
package
repository
import
(
"context"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const
openAI403CounterPrefix
=
"openai_403_count:account:"
var
openAI403CounterIncrScript
=
redis
.
NewScript
(
`
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, ttl)
end
return count
`
)
type
openAI403CounterCache
struct
{
rdb
*
redis
.
Client
}
func
NewOpenAI403CounterCache
(
rdb
*
redis
.
Client
)
service
.
OpenAI403CounterCache
{
return
&
openAI403CounterCache
{
rdb
:
rdb
}
}
func
(
c
*
openAI403CounterCache
)
IncrementOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
,
windowMinutes
int
)
(
int64
,
error
)
{
key
:=
fmt
.
Sprintf
(
"%s%d"
,
openAI403CounterPrefix
,
accountID
)
ttlSeconds
:=
windowMinutes
*
60
if
ttlSeconds
<
60
{
ttlSeconds
=
60
}
result
,
err
:=
openAI403CounterIncrScript
.
Run
(
ctx
,
c
.
rdb
,
[]
string
{
key
},
ttlSeconds
)
.
Int64
()
if
err
!=
nil
{
return
0
,
fmt
.
Errorf
(
"increment openai 403 count: %w"
,
err
)
}
return
result
,
nil
}
func
(
c
*
openAI403CounterCache
)
ResetOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
)
error
{
key
:=
fmt
.
Sprintf
(
"%s%d"
,
openAI403CounterPrefix
,
accountID
)
return
c
.
rdb
.
Del
(
ctx
,
key
)
.
Err
()
}
backend/internal/repository/wire.go
View file @
11cf23da
...
@@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet(
...
@@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet(
NewAPIKeyCache
,
NewAPIKeyCache
,
NewTempUnschedCache
,
NewTempUnschedCache
,
NewTimeoutCounterCache
,
NewTimeoutCounterCache
,
NewOpenAI403CounterCache
,
NewInternal500CounterCache
,
NewInternal500CounterCache
,
ProvideConcurrencyCache
,
ProvideConcurrencyCache
,
ProvideSessionLimitCache
,
ProvideSessionLimitCache
,
...
...
backend/internal/service/openai_403_counter.go
0 → 100644
View file @
11cf23da
package
service
import
"context"
// OpenAI403CounterCache 追踪 OpenAI 账号连续 403 失败次数。
type
OpenAI403CounterCache
interface
{
// IncrementOpenAI403Count 原子递增 403 计数并返回当前值。
IncrementOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
,
windowMinutes
int
)
(
int64
,
error
)
// ResetOpenAI403Count 成功后清零计数器。
ResetOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
)
error
}
backend/internal/service/openai_gateway_403_reset_test.go
0 → 100644
View file @
11cf23da
package
service
import
(
"context"
"testing"
"github.com/stretchr/testify/require"
)
type
openAI403CounterResetStub
struct
{
resetCalls
[]
int64
}
func
(
s
*
openAI403CounterResetStub
)
IncrementOpenAI403Count
(
context
.
Context
,
int64
,
int
)
(
int64
,
error
)
{
return
0
,
nil
}
func
(
s
*
openAI403CounterResetStub
)
ResetOpenAI403Count
(
_
context
.
Context
,
accountID
int64
)
error
{
s
.
resetCalls
=
append
(
s
.
resetCalls
,
accountID
)
return
nil
}
func
TestOpenAIGatewayServiceRecordUsage_ResetsOpenAI403CounterBeforeZeroUsageReturn
(
t
*
testing
.
T
)
{
counter
:=
&
openAI403CounterResetStub
{}
rateLimitSvc
:=
NewRateLimitService
(
nil
,
nil
,
nil
,
nil
,
nil
)
rateLimitSvc
.
SetOpenAI403CounterCache
(
counter
)
svc
:=
&
OpenAIGatewayService
{
rateLimitService
:
rateLimitSvc
,
}
err
:=
svc
.
RecordUsage
(
context
.
Background
(),
&
OpenAIRecordUsageInput
{
Result
:
&
OpenAIForwardResult
{},
Account
:
&
Account
{
ID
:
777
,
Platform
:
PlatformOpenAI
},
})
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
[]
int64
{
777
},
counter
.
resetCalls
)
}
backend/internal/service/openai_gateway_service.go
View file @
11cf23da
...
@@ -4425,6 +4425,9 @@ type OpenAIRecordUsageInput struct {
...
@@ -4425,6 +4425,9 @@ type OpenAIRecordUsageInput struct {
// RecordUsage records usage and deducts balance
// RecordUsage records usage and deducts balance
func
(
s
*
OpenAIGatewayService
)
RecordUsage
(
ctx
context
.
Context
,
input
*
OpenAIRecordUsageInput
)
error
{
func
(
s
*
OpenAIGatewayService
)
RecordUsage
(
ctx
context
.
Context
,
input
*
OpenAIRecordUsageInput
)
error
{
result
:=
input
.
Result
result
:=
input
.
Result
if
s
.
rateLimitService
!=
nil
&&
input
!=
nil
&&
input
.
Account
!=
nil
&&
input
.
Account
.
Platform
==
PlatformOpenAI
{
s
.
rateLimitService
.
ResetOpenAI403Counter
(
ctx
,
input
.
Account
.
ID
)
}
// 跳过所有 token 均为零的用量记录——上游未返回 usage 时不应写入数据库
// 跳过所有 token 均为零的用量记录——上游未返回 usage 时不应写入数据库
if
result
.
Usage
.
InputTokens
==
0
&&
result
.
Usage
.
OutputTokens
==
0
&&
if
result
.
Usage
.
InputTokens
==
0
&&
result
.
Usage
.
OutputTokens
==
0
&&
...
...
backend/internal/service/ratelimit_service.go
View file @
11cf23da
package
service
package
service
import
(
import
(
"bytes"
"context"
"context"
"encoding/json"
"encoding/json"
"fmt"
"log/slog"
"log/slog"
"net/http"
"net/http"
"strconv"
"strconv"
...
@@ -23,6 +25,7 @@ type RateLimitService struct {
...
@@ -23,6 +25,7 @@ type RateLimitService struct {
geminiQuotaService
*
GeminiQuotaService
geminiQuotaService
*
GeminiQuotaService
tempUnschedCache
TempUnschedCache
tempUnschedCache
TempUnschedCache
timeoutCounterCache
TimeoutCounterCache
timeoutCounterCache
TimeoutCounterCache
openAI403CounterCache
OpenAI403CounterCache
settingService
*
SettingService
settingService
*
SettingService
tokenCacheInvalidator
TokenCacheInvalidator
tokenCacheInvalidator
TokenCacheInvalidator
usageCacheMu
sync
.
RWMutex
usageCacheMu
sync
.
RWMutex
...
@@ -52,6 +55,12 @@ type geminiUsageTotalsBatchProvider interface {
...
@@ -52,6 +55,12 @@ type geminiUsageTotalsBatchProvider interface {
const
geminiPrecheckCacheTTL
=
time
.
Minute
const
geminiPrecheckCacheTTL
=
time
.
Minute
const
(
openAI403CooldownMinutesDefault
=
10
openAI403DisableThreshold
=
3
openAI403CounterWindowMinutes
=
180
)
// NewRateLimitService 创建RateLimitService实例
// NewRateLimitService 创建RateLimitService实例
func
NewRateLimitService
(
accountRepo
AccountRepository
,
usageRepo
UsageLogRepository
,
cfg
*
config
.
Config
,
geminiQuotaService
*
GeminiQuotaService
,
tempUnschedCache
TempUnschedCache
)
*
RateLimitService
{
func
NewRateLimitService
(
accountRepo
AccountRepository
,
usageRepo
UsageLogRepository
,
cfg
*
config
.
Config
,
geminiQuotaService
*
GeminiQuotaService
,
tempUnschedCache
TempUnschedCache
)
*
RateLimitService
{
return
&
RateLimitService
{
return
&
RateLimitService
{
...
@@ -69,6 +78,11 @@ func (s *RateLimitService) SetTimeoutCounterCache(cache TimeoutCounterCache) {
...
@@ -69,6 +78,11 @@ func (s *RateLimitService) SetTimeoutCounterCache(cache TimeoutCounterCache) {
s
.
timeoutCounterCache
=
cache
s
.
timeoutCounterCache
=
cache
}
}
// SetOpenAI403CounterCache 设置 OpenAI 403 连续失败计数器(可选依赖)
func
(
s
*
RateLimitService
)
SetOpenAI403CounterCache
(
cache
OpenAI403CounterCache
)
{
s
.
openAI403CounterCache
=
cache
}
// SetSettingService 设置系统设置服务(可选依赖)
// SetSettingService 设置系统设置服务(可选依赖)
func
(
s
*
RateLimitService
)
SetSettingService
(
settingService
*
SettingService
)
{
func
(
s
*
RateLimitService
)
SetSettingService
(
settingService
*
SettingService
)
{
s
.
settingService
=
settingService
s
.
settingService
=
settingService
...
@@ -655,6 +669,30 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
...
@@ -655,6 +669,30 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
slog
.
Warn
(
"account_disabled_auth_error"
,
"account_id"
,
account
.
ID
,
"error"
,
errorMsg
)
slog
.
Warn
(
"account_disabled_auth_error"
,
"account_id"
,
account
.
ID
,
"error"
,
errorMsg
)
}
}
func
buildForbiddenErrorMessage
(
prefix
string
,
upstreamMsg
string
,
responseBody
[]
byte
,
fallback
string
)
string
{
prefix
=
strings
.
TrimSpace
(
prefix
)
if
prefix
!=
""
&&
!
strings
.
HasSuffix
(
prefix
,
" "
)
{
prefix
+=
" "
}
if
msg
:=
strings
.
TrimSpace
(
upstreamMsg
);
msg
!=
""
{
return
prefix
+
msg
}
rawBody
:=
bytes
.
TrimSpace
(
responseBody
)
if
len
(
rawBody
)
>
0
{
if
json
.
Valid
(
rawBody
)
{
var
compact
bytes
.
Buffer
if
err
:=
json
.
Compact
(
&
compact
,
rawBody
);
err
==
nil
{
return
prefix
+
truncateForLog
(
compact
.
Bytes
(),
512
)
}
}
return
prefix
+
truncateForLog
(
rawBody
,
512
)
}
return
prefix
+
fallback
}
// handle403 处理 403 Forbidden 错误
// handle403 处理 403 Forbidden 错误
// Antigravity 平台区分 validation/violation/generic 三种类型,均 SetError 永久禁用;
// Antigravity 平台区分 validation/violation/generic 三种类型,均 SetError 永久禁用;
// 其他平台保持原有 SetError 行为。
// 其他平台保持原有 SetError 行为。
...
@@ -662,15 +700,64 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
...
@@ -662,15 +700,64 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
if
account
.
Platform
==
PlatformAntigravity
{
if
account
.
Platform
==
PlatformAntigravity
{
return
s
.
handleAntigravity403
(
ctx
,
account
,
upstreamMsg
,
responseBody
)
return
s
.
handleAntigravity403
(
ctx
,
account
,
upstreamMsg
,
responseBody
)
}
}
// 非 Antigravity 平台:保持原有行为
if
account
.
Platform
==
PlatformOpenAI
{
msg
:=
"Access forbidden (403): account may be suspended or lack permissions"
return
s
.
handleOpenAI403
(
ctx
,
account
,
upstreamMsg
,
responseBody
)
if
upstreamMsg
!=
""
{
msg
=
"Access forbidden (403): "
+
upstreamMsg
}
}
// 非 Antigravity 平台:保持原有行为
msg
:=
buildForbiddenErrorMessage
(
"Access forbidden (403):"
,
upstreamMsg
,
responseBody
,
"account may be suspended or lack permissions"
,
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
return
true
}
}
func
(
s
*
RateLimitService
)
handleOpenAI403
(
ctx
context
.
Context
,
account
*
Account
,
upstreamMsg
string
,
responseBody
[]
byte
)
(
shouldDisable
bool
)
{
msg
:=
buildForbiddenErrorMessage
(
"Access forbidden (403):"
,
upstreamMsg
,
responseBody
,
"account may be suspended or lack permissions"
,
)
if
s
.
openAI403CounterCache
==
nil
{
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
count
,
err
:=
s
.
openAI403CounterCache
.
IncrementOpenAI403Count
(
ctx
,
account
.
ID
,
openAI403CounterWindowMinutes
)
if
err
!=
nil
{
slog
.
Warn
(
"openai_403_increment_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
if
count
>=
openAI403DisableThreshold
{
msg
=
fmt
.
Sprintf
(
"%s | consecutive_403=%d/%d"
,
msg
,
count
,
openAI403DisableThreshold
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
until
:=
time
.
Now
()
.
Add
(
time
.
Duration
(
openAI403CooldownMinutesDefault
)
*
time
.
Minute
)
reason
:=
fmt
.
Sprintf
(
"OpenAI 403 temporary cooldown (%d/%d): %s"
,
count
,
openAI403DisableThreshold
,
msg
)
if
err
:=
s
.
accountRepo
.
SetTempUnschedulable
(
ctx
,
account
.
ID
,
until
,
reason
);
err
!=
nil
{
slog
.
Warn
(
"openai_403_set_temp_unschedulable_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
slog
.
Warn
(
"openai_403_temp_unschedulable"
,
"account_id"
,
account
.
ID
,
"until"
,
until
,
"count"
,
count
,
"threshold"
,
openAI403DisableThreshold
,
)
return
true
}
// handleAntigravity403 处理 Antigravity 平台的 403 错误
// handleAntigravity403 处理 Antigravity 平台的 403 错误
// validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复)
// validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复)
// violation(违规封号)→ 永久 SetError(需人工处理)
// violation(违规封号)→ 永久 SetError(需人工处理)
...
@@ -681,10 +768,12 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
...
@@ -681,10 +768,12 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
switch
fbType
{
switch
fbType
{
case
forbiddenTypeValidation
:
case
forbiddenTypeValidation
:
// VALIDATION_REQUIRED: 永久禁用,需人工去 Google 验证后手动恢复
// VALIDATION_REQUIRED: 永久禁用,需人工去 Google 验证后手动恢复
msg
:=
"Validation required (403): account needs Google verification"
msg
:=
buildForbiddenErrorMessage
(
if
upstreamMsg
!=
""
{
"Validation required (403):"
,
msg
=
"Validation required (403): "
+
upstreamMsg
upstreamMsg
,
}
responseBody
,
"account needs Google verification"
,
)
if
validationURL
:=
extractValidationURL
(
string
(
responseBody
));
validationURL
!=
""
{
if
validationURL
:=
extractValidationURL
(
string
(
responseBody
));
validationURL
!=
""
{
msg
+=
" | validation_url: "
+
validationURL
msg
+=
" | validation_url: "
+
validationURL
}
}
...
@@ -693,19 +782,23 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
...
@@ -693,19 +782,23 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
case
forbiddenTypeViolation
:
case
forbiddenTypeViolation
:
// 违规封号: 永久禁用,需人工处理
// 违规封号: 永久禁用,需人工处理
msg
:=
"Account violation (403): terms of service violation"
msg
:=
buildForbiddenErrorMessage
(
if
upstreamMsg
!=
""
{
"Account violation (403):"
,
msg
=
"Account violation (403): "
+
upstreamMsg
upstreamMsg
,
}
responseBody
,
"terms of service violation"
,
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
return
true
default
:
default
:
// 通用 403: 保持原有行为
// 通用 403: 保持原有行为
msg
:=
"Access forbidden (403): account may be suspended or lack permissions"
msg
:=
buildForbiddenErrorMessage
(
if
upstreamMsg
!=
""
{
"Access forbidden (403):"
,
msg
=
"Access forbidden (403): "
+
upstreamMsg
upstreamMsg
,
}
responseBody
,
"account may be suspended or lack permissions"
,
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
return
true
}
}
...
@@ -1221,9 +1314,19 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64)
...
@@ -1221,9 +1314,19 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64)
slog
.
Warn
(
"temp_unsched_cache_delete_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
slog
.
Warn
(
"temp_unsched_cache_delete_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
}
}
}
}
s
.
ResetOpenAI403Counter
(
ctx
,
accountID
)
return
nil
return
nil
}
}
func
(
s
*
RateLimitService
)
ResetOpenAI403Counter
(
ctx
context
.
Context
,
accountID
int64
)
{
if
s
==
nil
||
s
.
openAI403CounterCache
==
nil
||
accountID
<=
0
{
return
}
if
err
:=
s
.
openAI403CounterCache
.
ResetOpenAI403Count
(
ctx
,
accountID
);
err
!=
nil
{
slog
.
Warn
(
"openai_403_reset_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
}
}
// RecoverAccountState 按需恢复账号的可恢复运行时状态。
// RecoverAccountState 按需恢复账号的可恢复运行时状态。
func
(
s
*
RateLimitService
)
RecoverAccountState
(
ctx
context
.
Context
,
accountID
int64
,
options
AccountRecoveryOptions
)
(
*
SuccessfulTestRecoveryResult
,
error
)
{
func
(
s
*
RateLimitService
)
RecoverAccountState
(
ctx
context
.
Context
,
accountID
int64
,
options
AccountRecoveryOptions
)
(
*
SuccessfulTestRecoveryResult
,
error
)
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
...
@@ -1250,6 +1353,9 @@ func (s *RateLimitService) RecoverAccountState(ctx context.Context, accountID in
...
@@ -1250,6 +1353,9 @@ func (s *RateLimitService) RecoverAccountState(ctx context.Context, accountID in
}
}
result
.
ClearedRateLimit
=
true
result
.
ClearedRateLimit
=
true
}
}
if
result
.
ClearedError
||
result
.
ClearedRateLimit
{
s
.
ResetOpenAI403Counter
(
ctx
,
accountID
)
}
return
result
,
nil
return
result
,
nil
}
}
...
...
backend/internal/service/ratelimit_service_401_test.go
View file @
11cf23da
...
@@ -20,6 +20,7 @@ type rateLimitAccountRepoStub struct {
...
@@ -20,6 +20,7 @@ type rateLimitAccountRepoStub struct {
updateCredentialsCalls
int
updateCredentialsCalls
int
lastCredentials
map
[
string
]
any
lastCredentials
map
[
string
]
any
lastErrorMsg
string
lastErrorMsg
string
lastTempReason
string
}
}
func
(
r
*
rateLimitAccountRepoStub
)
SetError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
func
(
r
*
rateLimitAccountRepoStub
)
SetError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
...
@@ -30,6 +31,7 @@ func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, error
...
@@ -30,6 +31,7 @@ func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, error
func
(
r
*
rateLimitAccountRepoStub
)
SetTempUnschedulable
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
,
reason
string
)
error
{
func
(
r
*
rateLimitAccountRepoStub
)
SetTempUnschedulable
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
,
reason
string
)
error
{
r
.
tempCalls
++
r
.
tempCalls
++
r
.
lastTempReason
=
reason
return
nil
return
nil
}
}
...
@@ -44,6 +46,29 @@ type tokenCacheInvalidatorRecorder struct {
...
@@ -44,6 +46,29 @@ type tokenCacheInvalidatorRecorder struct {
err
error
err
error
}
}
type
openAI403CounterCacheStub
struct
{
counts
[]
int64
resetCalls
[]
int64
err
error
}
func
(
s
*
openAI403CounterCacheStub
)
IncrementOpenAI403Count
(
_
context
.
Context
,
_
int64
,
_
int
)
(
int64
,
error
)
{
if
s
.
err
!=
nil
{
return
0
,
s
.
err
}
if
len
(
s
.
counts
)
==
0
{
return
1
,
nil
}
count
:=
s
.
counts
[
0
]
s
.
counts
=
s
.
counts
[
1
:
]
return
count
,
nil
}
func
(
s
*
openAI403CounterCacheStub
)
ResetOpenAI403Count
(
_
context
.
Context
,
accountID
int64
)
error
{
s
.
resetCalls
=
append
(
s
.
resetCalls
,
accountID
)
return
nil
}
func
(
r
*
tokenCacheInvalidatorRecorder
)
InvalidateToken
(
ctx
context
.
Context
,
account
*
Account
)
error
{
func
(
r
*
tokenCacheInvalidatorRecorder
)
InvalidateToken
(
ctx
context
.
Context
,
account
*
Account
)
error
{
r
.
accounts
=
append
(
r
.
accounts
,
account
)
r
.
accounts
=
append
(
r
.
accounts
,
account
)
return
r
.
err
return
r
.
err
...
...
backend/internal/service/ratelimit_service_403_test.go
0 → 100644
View file @
11cf23da
//go:build unit
package
service
import
(
"context"
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func
TestRateLimitService_HandleUpstreamError_OpenAI403FirstHitTempUnschedulable
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
counter
:=
&
openAI403CounterCacheStub
{
counts
:
[]
int64
{
1
}}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetOpenAI403CounterCache
(
counter
)
account
:=
&
Account
{
ID
:
301
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
http
.
StatusForbidden
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"temporary edge rejection"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
0
,
repo
.
setErrorCalls
)
require
.
Equal
(
t
,
1
,
repo
.
tempCalls
)
require
.
Contains
(
t
,
repo
.
lastTempReason
,
"temporary edge rejection"
)
require
.
Contains
(
t
,
repo
.
lastTempReason
,
"(1/3)"
)
}
func
TestRateLimitService_HandleUpstreamError_OpenAI403ThresholdDisables
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
counter
:=
&
openAI403CounterCacheStub
{
counts
:
[]
int64
{
3
}}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetOpenAI403CounterCache
(
counter
)
account
:=
&
Account
{
ID
:
302
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
http
.
StatusForbidden
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"workspace forbidden by policy"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Equal
(
t
,
0
,
repo
.
tempCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"workspace forbidden by policy"
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"consecutive_403=3/3"
)
}
backend/internal/service/ratelimit_service_openai_test.go
View file @
11cf23da
...
@@ -7,6 +7,9 @@ import (
...
@@ -7,6 +7,9 @@ import (
"net/http"
"net/http"
"testing"
"testing"
"time"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
)
func
TestCalculateOpenAI429ResetTime_7dExhausted
(
t
*
testing
.
T
)
{
func
TestCalculateOpenAI429ResetTime_7dExhausted
(
t
*
testing
.
T
)
{
...
@@ -259,6 +262,53 @@ func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
...
@@ -259,6 +262,53 @@ func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
}
}
}
}
func
TestRateLimitService_HandleUpstreamError_403PreservesOriginalUpstreamMessage
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
account
:=
&
Account
{
ID
:
201
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
403
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"workspace forbidden by policy","type":"invalid_request_error"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"workspace forbidden by policy"
)
require
.
NotContains
(
t
,
repo
.
lastErrorMsg
,
"account may be suspended or lack permissions"
)
}
func
TestRateLimitService_HandleUpstreamError_403FallsBackToRawBody
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
account
:=
&
Account
{
ID
:
202
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
403
,
http
.
Header
{},
[]
byte
(
`{"error":{"type":"access_denied","details":{"reason":"ip_blocked"}}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
`"access_denied"`
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
`"ip_blocked"`
)
require
.
NotContains
(
t
,
repo
.
lastErrorMsg
,
"account may be suspended or lack permissions"
)
}
func
TestNormalizedCodexLimits_OnlySecondaryData
(
t
*
testing
.
T
)
{
func
TestNormalizedCodexLimits_OnlySecondaryData
(
t
*
testing
.
T
)
{
// Test when only secondary has data, no window_minutes
// Test when only secondary has data, no window_minutes
sUsed
:=
60.0
sUsed
:=
60.0
...
...
backend/internal/service/wire.go
View file @
11cf23da
...
@@ -210,11 +210,13 @@ func ProvideRateLimitService(
...
@@ -210,11 +210,13 @@ func ProvideRateLimitService(
geminiQuotaService
*
GeminiQuotaService
,
geminiQuotaService
*
GeminiQuotaService
,
tempUnschedCache
TempUnschedCache
,
tempUnschedCache
TempUnschedCache
,
timeoutCounterCache
TimeoutCounterCache
,
timeoutCounterCache
TimeoutCounterCache
,
openAI403CounterCache
OpenAI403CounterCache
,
settingService
*
SettingService
,
settingService
*
SettingService
,
tokenCacheInvalidator
TokenCacheInvalidator
,
tokenCacheInvalidator
TokenCacheInvalidator
,
)
*
RateLimitService
{
)
*
RateLimitService
{
svc
:=
NewRateLimitService
(
accountRepo
,
usageRepo
,
cfg
,
geminiQuotaService
,
tempUnschedCache
)
svc
:=
NewRateLimitService
(
accountRepo
,
usageRepo
,
cfg
,
geminiQuotaService
,
tempUnschedCache
)
svc
.
SetTimeoutCounterCache
(
timeoutCounterCache
)
svc
.
SetTimeoutCounterCache
(
timeoutCounterCache
)
svc
.
SetOpenAI403CounterCache
(
openAI403CounterCache
)
svc
.
SetSettingService
(
settingService
)
svc
.
SetSettingService
(
settingService
)
svc
.
SetTokenCacheInvalidator
(
tokenCacheInvalidator
)
svc
.
SetTokenCacheInvalidator
(
tokenCacheInvalidator
)
return
svc
return
svc
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment