365 lines
12 KiB
Go
365 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) {
|
|
svc := &RateLimitService{}
|
|
|
|
// Simulate headers when 7d limit is exhausted (100% used)
|
|
// Primary = 7d (10080 minutes), Secondary = 5h (300 minutes)
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "100")
|
|
headers.Set("x-codex-primary-reset-after-seconds", "384607") // ~4.5 days
|
|
headers.Set("x-codex-primary-window-minutes", "10080") // 7 days
|
|
headers.Set("x-codex-secondary-used-percent", "3")
|
|
headers.Set("x-codex-secondary-reset-after-seconds", "17369") // ~4.8 hours
|
|
headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours
|
|
|
|
before := time.Now()
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
after := time.Now()
|
|
|
|
if resetAt == nil {
|
|
t.Fatal("expected non-nil resetAt")
|
|
}
|
|
|
|
// Should be approximately 384607 seconds from now
|
|
expectedDuration := 384607 * time.Second
|
|
minExpected := before.Add(expectedDuration)
|
|
maxExpected := after.Add(expectedDuration)
|
|
|
|
if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
|
|
t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) {
|
|
svc := &RateLimitService{}
|
|
|
|
// Simulate headers when 5h limit is exhausted (100% used)
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "50")
|
|
headers.Set("x-codex-primary-reset-after-seconds", "500000")
|
|
headers.Set("x-codex-primary-window-minutes", "10080") // 7 days
|
|
headers.Set("x-codex-secondary-used-percent", "100")
|
|
headers.Set("x-codex-secondary-reset-after-seconds", "3600") // 1 hour
|
|
headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours
|
|
|
|
before := time.Now()
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
after := time.Now()
|
|
|
|
if resetAt == nil {
|
|
t.Fatal("expected non-nil resetAt")
|
|
}
|
|
|
|
// Should be approximately 3600 seconds from now
|
|
expectedDuration := 3600 * time.Second
|
|
minExpected := before.Add(expectedDuration)
|
|
maxExpected := after.Add(expectedDuration)
|
|
|
|
if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
|
|
t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) {
|
|
svc := &RateLimitService{}
|
|
|
|
// Neither limit at 100%, should use the longer reset time
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "80")
|
|
headers.Set("x-codex-primary-reset-after-seconds", "100000")
|
|
headers.Set("x-codex-primary-window-minutes", "10080")
|
|
headers.Set("x-codex-secondary-used-percent", "90")
|
|
headers.Set("x-codex-secondary-reset-after-seconds", "5000")
|
|
headers.Set("x-codex-secondary-window-minutes", "300")
|
|
|
|
before := time.Now()
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
after := time.Now()
|
|
|
|
if resetAt == nil {
|
|
t.Fatal("expected non-nil resetAt")
|
|
}
|
|
|
|
// Should use the max (100000 seconds from 7d window)
|
|
expectedDuration := 100000 * time.Second
|
|
minExpected := before.Add(expectedDuration)
|
|
maxExpected := after.Add(expectedDuration)
|
|
|
|
if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
|
|
t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) {
|
|
svc := &RateLimitService{}
|
|
|
|
// No codex headers at all
|
|
headers := http.Header{}
|
|
headers.Set("content-type", "application/json")
|
|
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
|
|
if resetAt != nil {
|
|
t.Errorf("expected nil resetAt when no codex headers, got %v", resetAt)
|
|
}
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {
|
|
svc := &RateLimitService{}
|
|
|
|
// Test when OpenAI sends primary as 5h and secondary as 7d (reversed)
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "100") // This is 5h
|
|
headers.Set("x-codex-primary-reset-after-seconds", "3600") // 1 hour
|
|
headers.Set("x-codex-primary-window-minutes", "300") // 5 hours - smaller!
|
|
headers.Set("x-codex-secondary-used-percent", "50")
|
|
headers.Set("x-codex-secondary-reset-after-seconds", "500000")
|
|
headers.Set("x-codex-secondary-window-minutes", "10080") // 7 days - larger!
|
|
|
|
before := time.Now()
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
after := time.Now()
|
|
|
|
if resetAt == nil {
|
|
t.Fatal("expected non-nil resetAt")
|
|
}
|
|
|
|
// Should correctly identify that primary is 5h (smaller window) and use its reset time
|
|
expectedDuration := 3600 * time.Second
|
|
minExpected := before.Add(expectedDuration)
|
|
maxExpected := after.Add(expectedDuration)
|
|
|
|
if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
|
|
t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestNormalizedCodexLimits(t *testing.T) {
|
|
// Test the Normalize() method directly
|
|
pUsed := 100.0
|
|
pReset := 384607
|
|
pWindow := 10080
|
|
sUsed := 3.0
|
|
sReset := 17369
|
|
sWindow := 300
|
|
|
|
snapshot := &OpenAICodexUsageSnapshot{
|
|
PrimaryUsedPercent: &pUsed,
|
|
PrimaryResetAfterSeconds: &pReset,
|
|
PrimaryWindowMinutes: &pWindow,
|
|
SecondaryUsedPercent: &sUsed,
|
|
SecondaryResetAfterSeconds: &sReset,
|
|
SecondaryWindowMinutes: &sWindow,
|
|
}
|
|
|
|
normalized := snapshot.Normalize()
|
|
if normalized == nil {
|
|
t.Fatal("expected non-nil normalized")
|
|
}
|
|
|
|
// Primary has larger window (10080 > 300), so primary should be 7d
|
|
if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 {
|
|
t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent)
|
|
}
|
|
if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 384607 {
|
|
t.Errorf("expected Reset7dSeconds=384607, got %v", normalized.Reset7dSeconds)
|
|
}
|
|
if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 3.0 {
|
|
t.Errorf("expected Used5hPercent=3, got %v", normalized.Used5hPercent)
|
|
}
|
|
if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 17369 {
|
|
t.Errorf("expected Reset5hSeconds=17369, got %v", normalized.Reset5hSeconds)
|
|
}
|
|
}
|
|
|
|
func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
|
|
// Test when only primary has data, no window_minutes
|
|
pUsed := 80.0
|
|
pReset := 50000
|
|
|
|
snapshot := &OpenAICodexUsageSnapshot{
|
|
PrimaryUsedPercent: &pUsed,
|
|
PrimaryResetAfterSeconds: &pReset,
|
|
// No window_minutes, no secondary data
|
|
}
|
|
|
|
normalized := snapshot.Normalize()
|
|
if normalized == nil {
|
|
t.Fatal("expected non-nil normalized")
|
|
}
|
|
|
|
// Legacy assumption: primary=7d, secondary=5h
|
|
if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 80.0 {
|
|
t.Errorf("expected Used7dPercent=80, got %v", normalized.Used7dPercent)
|
|
}
|
|
if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 50000 {
|
|
t.Errorf("expected Reset7dSeconds=50000, got %v", normalized.Reset7dSeconds)
|
|
}
|
|
// Secondary (5h) should be nil
|
|
if normalized.Used5hPercent != nil {
|
|
t.Errorf("expected Used5hPercent=nil, got %v", *normalized.Used5hPercent)
|
|
}
|
|
if normalized.Reset5hSeconds != nil {
|
|
t.Errorf("expected Reset5hSeconds=nil, got %v", *normalized.Reset5hSeconds)
|
|
}
|
|
}
|
|
|
|
func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) {
|
|
// Test when only secondary has data, no window_minutes
|
|
sUsed := 60.0
|
|
sReset := 3000
|
|
|
|
snapshot := &OpenAICodexUsageSnapshot{
|
|
SecondaryUsedPercent: &sUsed,
|
|
SecondaryResetAfterSeconds: &sReset,
|
|
// No window_minutes, no primary data
|
|
}
|
|
|
|
normalized := snapshot.Normalize()
|
|
if normalized == nil {
|
|
t.Fatal("expected non-nil normalized")
|
|
}
|
|
|
|
// Legacy assumption: primary=7d, secondary=5h
|
|
// So secondary goes to 5h
|
|
if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 60.0 {
|
|
t.Errorf("expected Used5hPercent=60, got %v", normalized.Used5hPercent)
|
|
}
|
|
if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 3000 {
|
|
t.Errorf("expected Reset5hSeconds=3000, got %v", normalized.Reset5hSeconds)
|
|
}
|
|
// Primary (7d) should be nil
|
|
if normalized.Used7dPercent != nil {
|
|
t.Errorf("expected Used7dPercent=nil, got %v", *normalized.Used7dPercent)
|
|
}
|
|
}
|
|
|
|
func TestNormalizedCodexLimits_BothDataNoWindowMinutes(t *testing.T) {
|
|
// Test when both have data but no window_minutes
|
|
pUsed := 100.0
|
|
pReset := 400000
|
|
sUsed := 50.0
|
|
sReset := 10000
|
|
|
|
snapshot := &OpenAICodexUsageSnapshot{
|
|
PrimaryUsedPercent: &pUsed,
|
|
PrimaryResetAfterSeconds: &pReset,
|
|
SecondaryUsedPercent: &sUsed,
|
|
SecondaryResetAfterSeconds: &sReset,
|
|
// No window_minutes
|
|
}
|
|
|
|
normalized := snapshot.Normalize()
|
|
if normalized == nil {
|
|
t.Fatal("expected non-nil normalized")
|
|
}
|
|
|
|
// Legacy assumption: primary=7d, secondary=5h
|
|
if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 {
|
|
t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent)
|
|
}
|
|
if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 400000 {
|
|
t.Errorf("expected Reset7dSeconds=400000, got %v", normalized.Reset7dSeconds)
|
|
}
|
|
if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 50.0 {
|
|
t.Errorf("expected Used5hPercent=50, got %v", normalized.Used5hPercent)
|
|
}
|
|
if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 10000 {
|
|
t.Errorf("expected Reset5hSeconds=10000, got %v", normalized.Reset5hSeconds)
|
|
}
|
|
}
|
|
|
|
func TestHandle429_AnthropicPlatformUnaffected(t *testing.T) {
|
|
// Verify that Anthropic platform accounts still use the original logic
|
|
// This test ensures we don't break existing Claude account rate limiting
|
|
|
|
svc := &RateLimitService{}
|
|
|
|
// Simulate Anthropic 429 headers
|
|
headers := http.Header{}
|
|
headers.Set("anthropic-ratelimit-unified-reset", "1737820800") // A future Unix timestamp
|
|
|
|
// For Anthropic platform, calculateOpenAI429ResetTime should return nil
|
|
// because it only handles OpenAI platform
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
|
|
// Should return nil since there are no x-codex-* headers
|
|
if resetAt != nil {
|
|
t.Errorf("expected nil for Anthropic headers, got %v", resetAt)
|
|
}
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_UserProvidedScenario(t *testing.T) {
|
|
// This is the exact scenario from the user:
|
|
// codex_7d_used_percent: 100
|
|
// codex_7d_reset_after_seconds: 384607 (约4.5天后重置)
|
|
// codex_5h_used_percent: 3
|
|
// codex_5h_reset_after_seconds: 17369 (约4.8小时后重置)
|
|
|
|
svc := &RateLimitService{}
|
|
|
|
// Simulate headers matching user's data
|
|
// Note: We need to map the canonical 5h/7d back to primary/secondary
|
|
// Based on typical OpenAI behavior: primary=7d (larger window), secondary=5h (smaller window)
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "100")
|
|
headers.Set("x-codex-primary-reset-after-seconds", "384607")
|
|
headers.Set("x-codex-primary-window-minutes", "10080") // 7 days = 10080 minutes
|
|
headers.Set("x-codex-secondary-used-percent", "3")
|
|
headers.Set("x-codex-secondary-reset-after-seconds", "17369")
|
|
headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours = 300 minutes
|
|
|
|
before := time.Now()
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
after := time.Now()
|
|
|
|
if resetAt == nil {
|
|
t.Fatal("expected non-nil resetAt for user scenario")
|
|
}
|
|
|
|
// Should use the 7d reset time (384607 seconds) since 7d limit is exhausted (100%)
|
|
expectedDuration := 384607 * time.Second
|
|
minExpected := before.Add(expectedDuration)
|
|
maxExpected := after.Add(expectedDuration)
|
|
|
|
if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
|
|
t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
|
|
}
|
|
|
|
// Verify it's approximately 4.45 days (384607 seconds)
|
|
duration := resetAt.Sub(before)
|
|
actualDays := duration.Hours() / 24.0
|
|
|
|
// 384607 / 86400 = ~4.45 days
|
|
if actualDays < 4.4 || actualDays > 4.5 {
|
|
t.Errorf("expected ~4.45 days, got %.2f days", actualDays)
|
|
}
|
|
|
|
t.Logf("User scenario: reset_at=%v, duration=%.2f days", resetAt, actualDays)
|
|
}
|
|
|
|
func TestCalculateOpenAI429ResetTime_5MinFallbackWhenNoReset(t *testing.T) {
|
|
// Test that we return nil when there's used_percent but no reset_after_seconds
|
|
// This should cause the caller to use the default 5-minute fallback
|
|
|
|
svc := &RateLimitService{}
|
|
|
|
headers := http.Header{}
|
|
headers.Set("x-codex-primary-used-percent", "100")
|
|
// No reset_after_seconds!
|
|
|
|
resetAt := svc.calculateOpenAI429ResetTime(headers)
|
|
|
|
// Should return nil since there's no reset time available
|
|
if resetAt != nil {
|
|
t.Errorf("expected nil when no reset_after_seconds, got %v", resetAt)
|
|
}
|
|
}
|