Commit 5e060b22 authored by erio's avatar erio
Browse files

Merge remote-tracking branch 'upstream/main' into feat/channel-insights

# Conflicts:
#	backend/cmd/server/wire_gen.go
parents 6f04c25e 0a80ec80
//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")
}
......@@ -7,6 +7,9 @@ import (
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func TestCalculateOpenAI429ResetTime_7dExhausted(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) {
// Test when only secondary has data, no window_minutes
sUsed := 60.0
......
This diff is collapsed.
......@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency int
DefaultBalance float64
DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting
// Model fallback configuration
......
......@@ -49,6 +49,15 @@ type User struct {
BalanceNotifyExtraEmails []NotifyEmailEntry
TotalRecharged float64
// RPMLimit 用户级每分钟请求数上限(0 = 不限制)。仅在所用分组未设置 rpm_limit
// 且该 (用户, 分组) 无 rpm_override 时作为全局兜底生效,计数键 rpm:u:{userID}:{min}。
RPMLimit int
// UserGroupRPMOverride 来自 auth cache snapshot 的 (user, group) RPM 覆盖值。
// nil = 该 API Key 对应的 (user, group) 无 override;非 nil 时 checkRPM 直接使用,
// 避免每请求查 DB。字段不持久化到数据库。
UserGroupRPMOverride *int
APIKeys []APIKey
Subscriptions []UserSubscription
}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
-- Add per-group Requests-Per-Minute limit.
-- rpm_limit: 分组统一 RPM 上限(0 = 不限制)。
-- 一旦配置即接管该用户在该分组的限流,覆盖用户级 users.rpm_limit。
-- 计数键:rpm:ug:{user_id}:{group_id}:{minute}。
ALTER TABLE groups ADD COLUMN IF NOT EXISTS rpm_limit integer NOT NULL DEFAULT 0;
COMMENT ON COLUMN groups.rpm_limit IS '分组 RPM 上限;0 表示不限制;设置后接管该分组用户的限流(覆盖用户级 rpm_limit)。';
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment