| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- //go:build unit
- package service
- import (
- "context"
- "net/http"
- "strconv"
- "testing"
- "time"
- "github.com/stretchr/testify/require"
- )
- type anthropicWindowLimitRepo struct {
- mockAccountRepoForGemini
- rateLimitCalls int
- tempUnschedCalls int
- lastRateLimitReset time.Time
- modelRateLimitCalls int
- lastModelRateLimitScope string
- lastModelRateLimitReset time.Time
- sessionWindowCalls int
- lastExtraUpdates map[string]any
- }
- func (r *anthropicWindowLimitRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error {
- r.rateLimitCalls++
- r.lastRateLimitReset = resetAt
- return nil
- }
- func (r *anthropicWindowLimitRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
- r.tempUnschedCalls++
- return nil
- }
- func (r *anthropicWindowLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, resetAt time.Time, _ ...string) error {
- r.modelRateLimitCalls++
- r.lastModelRateLimitScope = scope
- r.lastModelRateLimitReset = resetAt
- return nil
- }
- func (r *anthropicWindowLimitRepo) UpdateSessionWindow(_ context.Context, _ int64, _, _ *time.Time, _ string) error {
- r.sessionWindowCalls++
- return nil
- }
- func (r *anthropicWindowLimitRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
- r.lastExtraUpdates = updates
- return nil
- }
- func TestHandleUpstreamError_AnthropicWindowLimitPreemptsTempUnschedRule(t *testing.T) {
- resetAt := time.Now().Add(3 * time.Hour).Truncate(time.Second)
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.02")
- headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(resetAt.Unix(), 10))
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{
- ID: 42,
- Type: AccountTypeOAuth,
- Platform: PlatformAnthropic,
- Credentials: map[string]any{
- "temp_unschedulable_enabled": true,
- "temp_unschedulable_rules": []any{
- map[string]any{
- "error_code": float64(http.StatusTooManyRequests),
- "keywords": []any{"rate limit"},
- "duration_minutes": float64(10),
- },
- },
- },
- }
- svc.HandleUpstreamError(
- context.Background(),
- account,
- http.StatusTooManyRequests,
- headers,
- []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."}}`),
- )
- require.Zero(t, repo.tempUnschedCalls, "official Anthropic window limits should not be shortened by local temp-unsched rules")
- require.Equal(t, 1, repo.rateLimitCalls)
- require.Equal(t, resetAt, repo.lastRateLimitReset)
- }
- // fable429Headers 构造 7d_oi(Fable 专属 7d 窗口)触发 429 的完整响应头,
- // 数值取自真实抓包(5h/7d 均 allowed,仅 7d_oi rejected)。
- func fable429Headers(reset5h, resetOI time.Time) http.Header {
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
- headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.41")
- headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(resetOI.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-7d-status", "allowed")
- headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.56")
- headers.Set("anthropic-ratelimit-unified-7d_oi-reset", strconv.FormatInt(resetOI.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-7d_oi-status", "rejected")
- headers.Set("anthropic-ratelimit-unified-7d_oi-surpassed-threshold", "1.0")
- headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "1.0")
- headers.Set("anthropic-ratelimit-unified-fallback-percentage", "0.5")
- headers.Set("anthropic-ratelimit-unified-overage-disabled-reason", "org_level_disabled")
- headers.Set("anthropic-ratelimit-unified-overage-status", "rejected")
- headers.Set("anthropic-ratelimit-unified-representative-claim", "seven_day_overage_included")
- headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetOI.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-status", "rejected")
- return headers
- }
- func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) {
- now := time.Now()
- reset5h := now.Add(2 * time.Hour).Truncate(time.Second)
- resetOI := now.Add(80 * time.Hour).Truncate(time.Second)
- headers := fable429Headers(reset5h, resetOI)
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{
- ID: 42,
- Type: AccountTypeOAuth,
- Platform: PlatformAnthropic,
- Credentials: map[string]any{
- "temp_unschedulable_enabled": true,
- "temp_unschedulable_rules": []any{
- map[string]any{
- "error_code": float64(http.StatusTooManyRequests),
- "keywords": []any{"rate limit"},
- "duration_minutes": float64(10),
- },
- },
- },
- }
- shouldDisable := svc.HandleUpstreamError(
- context.Background(),
- account,
- http.StatusTooManyRequests,
- headers,
- []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."}}`),
- "claude-fable-5",
- )
- require.False(t, shouldDisable)
- require.Zero(t, repo.rateLimitCalls, "7d_oi (Fable-only) window must not mark the whole account rate limited")
- require.Zero(t, repo.tempUnschedCalls, "7d_oi window must not trigger local temp-unsched rules")
- require.Zero(t, repo.sessionWindowCalls, "7d_oi window must not rewrite the 5h session window as rejected")
- require.Equal(t, 1, repo.modelRateLimitCalls)
- require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope)
- require.Equal(t, resetOI, repo.lastModelRateLimitReset)
- // 429 响应头也要被动采样,避免 7d F 进度条在限流期内冻结在旧值
- require.NotNil(t, repo.lastExtraUpdates)
- require.Equal(t, 1.0, repo.lastExtraUpdates["passive_usage_7d_oi_utilization"])
- require.Equal(t, resetOI.Unix(), repo.lastExtraUpdates["passive_usage_7d_oi_reset"])
- require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"])
- }
- func TestHandleUpstreamError_AnthropicFableCreditsRequiredOnlyMarksModelRateLimit(t *testing.T) {
- resetAt := time.Now().Add(29 * 24 * time.Hour).Truncate(time.Second)
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10))
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5","disabled_reason":"org_level_disabled"},"message":"Usage credits are required for this model."}}`)
- shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5")
- require.False(t, shouldDisable)
- require.Zero(t, repo.rateLimitCalls, "Fable entitlement failure must not rate limit the whole account")
- require.Zero(t, repo.sessionWindowCalls, "Fable entitlement failure must not rewrite the shared session window")
- require.Equal(t, 1, repo.modelRateLimitCalls)
- require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope)
- require.Equal(t, resetAt, repo.lastModelRateLimitReset)
- }
- func TestHandleUpstreamError_AnthropicFableCreditsRequiredFallsBackToRequestedModel(t *testing.T) {
- startedAt := time.Now()
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required"},"message":"Usage credits are required for this model."}}`)
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body, "claude-fable-5[1m]")
- require.Zero(t, repo.rateLimitCalls)
- require.Equal(t, 1, repo.modelRateLimitCalls)
- require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope)
- require.True(t, repo.lastModelRateLimitReset.After(startedAt))
- }
- func TestHandleUpstreamError_AnthropicNonFableCreditsRequiredKeepsLegacyBehavior(t *testing.T) {
- resetAt := time.Now().Add(2 * time.Hour).Truncate(time.Second)
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10))
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-opus-5"},"message":"Usage credits are required for this model."}}`)
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-opus-5")
- require.Zero(t, repo.modelRateLimitCalls)
- require.Equal(t, 1, repo.rateLimitCalls)
- require.Equal(t, resetAt, repo.lastRateLimitReset)
- }
- func TestHandleUpstreamError_AnthropicSharedWindowStillWinsWithFableCreditsRequired(t *testing.T) {
- now := time.Now()
- reset5h := now.Add(2 * time.Hour).Truncate(time.Second)
- reset7d := now.Add(80 * time.Hour).Truncate(time.Second)
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-5h-status", "rejected")
- headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0")
- headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-7d-status", "allowed")
- headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset5h.Unix(), 10))
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5"}}}`)
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5")
- require.Equal(t, 1, repo.modelRateLimitCalls, "Fable-specific failure should still be recorded")
- require.Equal(t, 1, repo.rateLimitCalls, "an explicitly rejected shared window must still rate limit the account")
- require.Equal(t, reset5h, repo.lastRateLimitReset)
- }
- func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) {
- // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。
- now := time.Now()
- reset5h := now.Add(2 * time.Hour).Truncate(time.Second)
- resetOI := now.Add(80 * time.Hour).Truncate(time.Second)
- headers := fable429Headers(reset5h, resetOI)
- headers.Set("anthropic-ratelimit-unified-5h-status", "rejected")
- headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0")
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5")
- require.Equal(t, 1, repo.rateLimitCalls, "exhausted 5h window must still rate limit the account")
- require.Equal(t, reset5h, repo.lastRateLimitReset)
- require.Equal(t, 1, repo.modelRateLimitCalls)
- require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope)
- }
- func TestHandleUpstreamError_AnthropicAccountWindowStillWinsOver7dOi(t *testing.T) {
- // 7d 窗口真超限时必须仍按账号级限流处理,同时记录 Fable 模型限流。
- now := time.Now()
- reset5h := now.Add(2 * time.Hour).Truncate(time.Second)
- resetOI := now.Add(80 * time.Hour).Truncate(time.Second)
- headers := fable429Headers(reset5h, resetOI)
- headers.Set("anthropic-ratelimit-unified-7d-status", "rejected")
- headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.02")
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5")
- require.Equal(t, 1, repo.rateLimitCalls, "exhausted 7d window must still rate limit the account")
- require.Equal(t, resetOI, repo.lastRateLimitReset)
- require.Equal(t, 1, repo.modelRateLimitCalls, "Fable model rate limit should also be recorded")
- require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope)
- }
- func TestHandleUpstreamError_Anthropic429Without7dOiKeepsLegacyBehavior(t *testing.T) {
- // 无 7d_oi 头、5h/7d 均未超限的 429:保持旧行为(按较早 reset 标记账号限流)。
- now := time.Now()
- reset5h := now.Add(2 * time.Hour).Truncate(time.Second)
- reset7d := now.Add(80 * time.Hour).Truncate(time.Second)
- headers := http.Header{}
- headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
- headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.41")
- headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10))
- headers.Set("anthropic-ratelimit-unified-7d-status", "allowed")
- headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.56")
- repo := &anthropicWindowLimitRepo{}
- svc := NewRateLimitService(repo, nil, nil, nil, nil)
- account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic}
- svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5")
- require.Zero(t, repo.modelRateLimitCalls, "no 7d_oi signal → no model rate limit")
- require.Equal(t, 1, repo.rateLimitCalls)
- require.Equal(t, reset5h, repo.lastRateLimitReset, "legacy path picks the sooner reset")
- require.Equal(t, 1, repo.sessionWindowCalls)
- }
|