ratelimit_service_openai_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) {
  12. svc := &RateLimitService{}
  13. // Simulate headers when 7d limit is exhausted (100% used)
  14. // Primary = 7d (10080 minutes), Secondary = 5h (300 minutes)
  15. headers := http.Header{}
  16. headers.Set("x-codex-primary-used-percent", "100")
  17. headers.Set("x-codex-primary-reset-after-seconds", "384607") // ~4.5 days
  18. headers.Set("x-codex-primary-window-minutes", "10080") // 7 days
  19. headers.Set("x-codex-secondary-used-percent", "3")
  20. headers.Set("x-codex-secondary-reset-after-seconds", "17369") // ~4.8 hours
  21. headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours
  22. before := time.Now()
  23. resetAt := svc.calculateOpenAI429ResetTime(headers)
  24. after := time.Now()
  25. if resetAt == nil {
  26. t.Fatal("expected non-nil resetAt")
  27. }
  28. // Should be approximately 384607 seconds from now
  29. expectedDuration := 384607 * time.Second
  30. minExpected := before.Add(expectedDuration)
  31. maxExpected := after.Add(expectedDuration)
  32. if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
  33. t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
  34. }
  35. }
  36. func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) {
  37. svc := &RateLimitService{}
  38. // Simulate headers when 5h limit is exhausted (100% used)
  39. headers := http.Header{}
  40. headers.Set("x-codex-primary-used-percent", "50")
  41. headers.Set("x-codex-primary-reset-after-seconds", "500000")
  42. headers.Set("x-codex-primary-window-minutes", "10080") // 7 days
  43. headers.Set("x-codex-secondary-used-percent", "100")
  44. headers.Set("x-codex-secondary-reset-after-seconds", "3600") // 1 hour
  45. headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours
  46. before := time.Now()
  47. resetAt := svc.calculateOpenAI429ResetTime(headers)
  48. after := time.Now()
  49. if resetAt == nil {
  50. t.Fatal("expected non-nil resetAt")
  51. }
  52. // Should be approximately 3600 seconds from now
  53. expectedDuration := 3600 * time.Second
  54. minExpected := before.Add(expectedDuration)
  55. maxExpected := after.Add(expectedDuration)
  56. if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
  57. t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
  58. }
  59. }
  60. func TestCalculateOpenAI429ResetTime_NeitherExhausted_ReturnsNil(t *testing.T) {
  61. svc := &RateLimitService{}
  62. headers := http.Header{}
  63. headers.Set("x-codex-primary-used-percent", "80")
  64. headers.Set("x-codex-primary-reset-after-seconds", "100000")
  65. headers.Set("x-codex-primary-window-minutes", "10080")
  66. headers.Set("x-codex-secondary-used-percent", "90")
  67. headers.Set("x-codex-secondary-reset-after-seconds", "5000")
  68. headers.Set("x-codex-secondary-window-minutes", "300")
  69. require.Nil(t, svc.calculateOpenAI429ResetTime(headers))
  70. }
  71. func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) {
  72. svc := &RateLimitService{}
  73. // No codex headers at all
  74. headers := http.Header{}
  75. headers.Set("content-type", "application/json")
  76. resetAt := svc.calculateOpenAI429ResetTime(headers)
  77. if resetAt != nil {
  78. t.Errorf("expected nil resetAt when no codex headers, got %v", resetAt)
  79. }
  80. }
  81. func TestParseOpenAIRateLimitResetTime_OpenCodeGoUsageLimit(t *testing.T) {
  82. tests := []struct {
  83. name string
  84. body string
  85. want time.Duration
  86. }{
  87. {
  88. name: "days",
  89. body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 2 days."}}`,
  90. want: 48 * time.Hour,
  91. },
  92. {
  93. name: "hours",
  94. body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 18 hours."}}`,
  95. want: 18 * time.Hour,
  96. },
  97. {
  98. name: "hours and minutes",
  99. body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min."}}`,
  100. want: 4*time.Hour + 59*time.Minute,
  101. },
  102. }
  103. for _, tt := range tests {
  104. t.Run(tt.name, func(t *testing.T) {
  105. before := time.Now()
  106. resetAt := parseOpenAIRateLimitResetTime([]byte(tt.body))
  107. after := time.Now()
  108. require.NotNil(t, resetAt)
  109. actual := time.Unix(*resetAt, 0)
  110. require.False(t, actual.Before(before.Add(tt.want).Truncate(time.Second)))
  111. require.False(t, actual.After(after.Add(tt.want)))
  112. })
  113. }
  114. }
  115. func TestParseOpenAIRateLimitResetTime_DoesNotParseUnknownErrorMessage(t *testing.T) {
  116. body := []byte(`{"error":{"type":"rate_limit_error","message":"Resets in 2 days."}}`)
  117. require.Nil(t, parseOpenAIRateLimitResetTime(body))
  118. }
  119. func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {
  120. svc := &RateLimitService{}
  121. // Test when OpenAI sends primary as 5h and secondary as 7d (reversed)
  122. headers := http.Header{}
  123. headers.Set("x-codex-primary-used-percent", "100") // This is 5h
  124. headers.Set("x-codex-primary-reset-after-seconds", "3600") // 1 hour
  125. headers.Set("x-codex-primary-window-minutes", "300") // 5 hours - smaller!
  126. headers.Set("x-codex-secondary-used-percent", "50")
  127. headers.Set("x-codex-secondary-reset-after-seconds", "500000")
  128. headers.Set("x-codex-secondary-window-minutes", "10080") // 7 days - larger!
  129. before := time.Now()
  130. resetAt := svc.calculateOpenAI429ResetTime(headers)
  131. after := time.Now()
  132. if resetAt == nil {
  133. t.Fatal("expected non-nil resetAt")
  134. }
  135. // Should correctly identify that primary is 5h (smaller window) and use its reset time
  136. expectedDuration := 3600 * time.Second
  137. minExpected := before.Add(expectedDuration)
  138. maxExpected := after.Add(expectedDuration)
  139. if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
  140. t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
  141. }
  142. }
  143. type openAI429SnapshotRepo struct {
  144. mockAccountRepoForGemini
  145. rateLimitedID int64
  146. updatedExtra map[string]any
  147. bulkUpdatedIDs []int64
  148. bulkUpdatedPayload AccountBulkUpdate
  149. }
  150. func (r *openAI429SnapshotRepo) SetRateLimited(_ context.Context, id int64, _ time.Time) error {
  151. r.rateLimitedID = id
  152. return nil
  153. }
  154. func (r *openAI429SnapshotRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
  155. r.updatedExtra = updates
  156. return nil
  157. }
  158. func (r *openAI429SnapshotRepo) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
  159. r.bulkUpdatedIDs = append([]int64(nil), ids...)
  160. r.bulkUpdatedPayload = updates
  161. return int64(len(ids)), nil
  162. }
  163. func TestHandle429_OpenAIPersistsCodexSnapshotImmediately(t *testing.T) {
  164. repo := &openAI429SnapshotRepo{}
  165. svc := NewRateLimitService(repo, nil, nil, nil, nil)
  166. account := &Account{ID: 123, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  167. headers := http.Header{}
  168. headers.Set("x-codex-primary-used-percent", "100")
  169. headers.Set("x-codex-primary-reset-after-seconds", "604800")
  170. headers.Set("x-codex-primary-window-minutes", "10080")
  171. headers.Set("x-codex-secondary-used-percent", "100")
  172. headers.Set("x-codex-secondary-reset-after-seconds", "18000")
  173. headers.Set("x-codex-secondary-window-minutes", "300")
  174. svc.handle429(context.Background(), account, headers, nil)
  175. if repo.rateLimitedID != account.ID {
  176. t.Fatalf("rateLimitedID = %d, want %d", repo.rateLimitedID, account.ID)
  177. }
  178. if len(repo.updatedExtra) == 0 {
  179. t.Fatal("expected codex snapshot to be persisted on 429")
  180. }
  181. if got := repo.updatedExtra["codex_5h_used_percent"]; got != 100.0 {
  182. t.Fatalf("codex_5h_used_percent = %v, want 100", got)
  183. }
  184. if got := repo.updatedExtra["codex_7d_used_percent"]; got != 100.0 {
  185. t.Fatalf("codex_7d_used_percent = %v, want 100", got)
  186. }
  187. }
  188. func TestHandle429_OpenAISyncsObservedPlanType(t *testing.T) {
  189. repo := &openAI429SnapshotRepo{}
  190. svc := NewRateLimitService(repo, nil, nil, nil, nil)
  191. account := &Account{
  192. ID: 124,
  193. Platform: PlatformOpenAI,
  194. Type: AccountTypeOAuth,
  195. Credentials: map[string]any{"plan_type": "plus"},
  196. }
  197. body := []byte(`{"error":{"type":"usage_limit_reached","message":"limit reached","plan_type":"free","resets_at":1777283883}}`)
  198. svc.handle429(context.Background(), account, http.Header{}, body)
  199. require.Equal(t, []int64{account.ID}, repo.bulkUpdatedIDs)
  200. require.Equal(t, "free", repo.bulkUpdatedPayload.Credentials["plan_type"])
  201. require.Equal(t, "free", account.Credentials["plan_type"])
  202. require.Equal(t, account.ID, repo.rateLimitedID)
  203. }
  204. // TestHandle429_SkipsSparkShadow 外审第8轮 P1:spark 影子的限流状态只由 QueryUsage(/wham/usage
  205. // codex_bengalfox)维护;/responses 429 携带的 global x-codex-* 不得对影子做任何 DB 限流写入,
  206. // 否则会把 spark 误耦合到 global codex 窗口、冷却到 global reset。
  207. func TestHandle429_SkipsSparkShadow(t *testing.T) {
  208. headers := http.Header{}
  209. headers.Set("x-codex-primary-used-percent", "100")
  210. headers.Set("x-codex-primary-reset-after-seconds", "604800")
  211. headers.Set("x-codex-primary-window-minutes", "10080")
  212. headers.Set("x-codex-secondary-used-percent", "100")
  213. headers.Set("x-codex-secondary-reset-after-seconds", "18000")
  214. headers.Set("x-codex-secondary-window-minutes", "300")
  215. parentID := int64(900)
  216. shadowRepo := &openAI429SnapshotRepo{}
  217. shadowSvc := NewRateLimitService(shadowRepo, nil, nil, nil, nil)
  218. shadow := &Account{
  219. ID: 901,
  220. Platform: PlatformOpenAI,
  221. Type: AccountTypeOAuth,
  222. ParentAccountID: &parentID,
  223. QuotaDimension: QuotaDimensionSpark,
  224. }
  225. shadowSvc.handle429(context.Background(), shadow, headers, nil)
  226. require.Zero(t, shadowRepo.rateLimitedID, "spark shadow must not be SetRateLimited from /responses global 429")
  227. require.Empty(t, shadowRepo.updatedExtra, "spark shadow must not get a codex snapshot from /responses 429")
  228. // 反向对照:普通 OpenAI OAuth 账号仍按 global 429 限流。
  229. normalRepo := &openAI429SnapshotRepo{}
  230. normalSvc := NewRateLimitService(normalRepo, nil, nil, nil, nil)
  231. normal := &Account{ID: 902, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  232. normalSvc.handle429(context.Background(), normal, headers, nil)
  233. require.Equal(t, normal.ID, normalRepo.rateLimitedID, "normal OpenAI OAuth account should still be rate limited")
  234. }
  235. func TestNormalizedCodexLimits(t *testing.T) {
  236. // Test the Normalize() method directly
  237. pUsed := 100.0
  238. pReset := 384607
  239. pWindow := 10080
  240. sUsed := 3.0
  241. sReset := 17369
  242. sWindow := 300
  243. snapshot := &OpenAICodexUsageSnapshot{
  244. PrimaryUsedPercent: &pUsed,
  245. PrimaryResetAfterSeconds: &pReset,
  246. PrimaryWindowMinutes: &pWindow,
  247. SecondaryUsedPercent: &sUsed,
  248. SecondaryResetAfterSeconds: &sReset,
  249. SecondaryWindowMinutes: &sWindow,
  250. }
  251. normalized := snapshot.Normalize()
  252. if normalized == nil {
  253. t.Fatal("expected non-nil normalized")
  254. }
  255. // Primary has larger window (10080 > 300), so primary should be 7d
  256. if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 {
  257. t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent)
  258. }
  259. if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 384607 {
  260. t.Errorf("expected Reset7dSeconds=384607, got %v", normalized.Reset7dSeconds)
  261. }
  262. if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 3.0 {
  263. t.Errorf("expected Used5hPercent=3, got %v", normalized.Used5hPercent)
  264. }
  265. if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 17369 {
  266. t.Errorf("expected Reset5hSeconds=17369, got %v", normalized.Reset5hSeconds)
  267. }
  268. }
  269. func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
  270. // Test when only primary has data, no window_minutes
  271. pUsed := 80.0
  272. pReset := 50000
  273. snapshot := &OpenAICodexUsageSnapshot{
  274. PrimaryUsedPercent: &pUsed,
  275. PrimaryResetAfterSeconds: &pReset,
  276. // No window_minutes, no secondary data
  277. }
  278. normalized := snapshot.Normalize()
  279. if normalized == nil {
  280. t.Fatal("expected non-nil normalized")
  281. }
  282. // Legacy assumption: primary=7d, secondary=5h
  283. if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 80.0 {
  284. t.Errorf("expected Used7dPercent=80, got %v", normalized.Used7dPercent)
  285. }
  286. if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 50000 {
  287. t.Errorf("expected Reset7dSeconds=50000, got %v", normalized.Reset7dSeconds)
  288. }
  289. // Secondary (5h) should be nil
  290. if normalized.Used5hPercent != nil {
  291. t.Errorf("expected Used5hPercent=nil, got %v", *normalized.Used5hPercent)
  292. }
  293. if normalized.Reset5hSeconds != nil {
  294. t.Errorf("expected Reset5hSeconds=nil, got %v", *normalized.Reset5hSeconds)
  295. }
  296. }
  297. func TestRateLimitService_HandleUpstreamError_403PreservesOriginalUpstreamMessage(t *testing.T) {
  298. repo := &rateLimitAccountRepoStub{}
  299. service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  300. account := &Account{
  301. ID: 201,
  302. Platform: PlatformOpenAI,
  303. Type: AccountTypeOAuth,
  304. }
  305. shouldDisable := service.HandleUpstreamError(
  306. context.Background(),
  307. account,
  308. 403,
  309. http.Header{},
  310. []byte(`{"error":{"message":"workspace forbidden by policy","type":"invalid_request_error"}}`),
  311. )
  312. require.True(t, shouldDisable)
  313. require.Equal(t, 1, repo.setErrorCalls)
  314. require.Contains(t, repo.lastErrorMsg, "workspace forbidden by policy")
  315. require.NotContains(t, repo.lastErrorMsg, "account may be suspended or lack permissions")
  316. }
  317. func TestRateLimitService_HandleUpstreamError_403FallsBackToRawBody(t *testing.T) {
  318. repo := &rateLimitAccountRepoStub{}
  319. service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  320. account := &Account{
  321. ID: 202,
  322. Platform: PlatformOpenAI,
  323. Type: AccountTypeOAuth,
  324. }
  325. shouldDisable := service.HandleUpstreamError(
  326. context.Background(),
  327. account,
  328. 403,
  329. http.Header{},
  330. []byte(`{"error":{"type":"access_denied","details":{"reason":"ip_blocked"}}}`),
  331. )
  332. require.True(t, shouldDisable)
  333. require.Equal(t, 1, repo.setErrorCalls)
  334. require.Contains(t, repo.lastErrorMsg, `"access_denied"`)
  335. require.Contains(t, repo.lastErrorMsg, `"ip_blocked"`)
  336. require.NotContains(t, repo.lastErrorMsg, "account may be suspended or lack permissions")
  337. }
  338. func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) {
  339. // Test when only secondary has data, no window_minutes
  340. sUsed := 60.0
  341. sReset := 3000
  342. snapshot := &OpenAICodexUsageSnapshot{
  343. SecondaryUsedPercent: &sUsed,
  344. SecondaryResetAfterSeconds: &sReset,
  345. // No window_minutes, no primary data
  346. }
  347. normalized := snapshot.Normalize()
  348. if normalized == nil {
  349. t.Fatal("expected non-nil normalized")
  350. }
  351. // Legacy assumption: primary=7d, secondary=5h
  352. // So secondary goes to 5h
  353. if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 60.0 {
  354. t.Errorf("expected Used5hPercent=60, got %v", normalized.Used5hPercent)
  355. }
  356. if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 3000 {
  357. t.Errorf("expected Reset5hSeconds=3000, got %v", normalized.Reset5hSeconds)
  358. }
  359. // Primary (7d) should be nil
  360. if normalized.Used7dPercent != nil {
  361. t.Errorf("expected Used7dPercent=nil, got %v", *normalized.Used7dPercent)
  362. }
  363. }
  364. func TestNormalizedCodexLimits_BothDataNoWindowMinutes(t *testing.T) {
  365. // Test when both have data but no window_minutes
  366. pUsed := 100.0
  367. pReset := 400000
  368. sUsed := 50.0
  369. sReset := 10000
  370. snapshot := &OpenAICodexUsageSnapshot{
  371. PrimaryUsedPercent: &pUsed,
  372. PrimaryResetAfterSeconds: &pReset,
  373. SecondaryUsedPercent: &sUsed,
  374. SecondaryResetAfterSeconds: &sReset,
  375. // No window_minutes
  376. }
  377. normalized := snapshot.Normalize()
  378. if normalized == nil {
  379. t.Fatal("expected non-nil normalized")
  380. }
  381. // Legacy assumption: primary=7d, secondary=5h
  382. if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 {
  383. t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent)
  384. }
  385. if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 400000 {
  386. t.Errorf("expected Reset7dSeconds=400000, got %v", normalized.Reset7dSeconds)
  387. }
  388. if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 50.0 {
  389. t.Errorf("expected Used5hPercent=50, got %v", normalized.Used5hPercent)
  390. }
  391. if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 10000 {
  392. t.Errorf("expected Reset5hSeconds=10000, got %v", normalized.Reset5hSeconds)
  393. }
  394. }
  395. func TestHandle429_AnthropicPlatformUnaffected(t *testing.T) {
  396. // Verify that Anthropic platform accounts still use the original logic
  397. // This test ensures we don't break existing Claude account rate limiting
  398. svc := &RateLimitService{}
  399. // Simulate Anthropic 429 headers
  400. headers := http.Header{}
  401. headers.Set("anthropic-ratelimit-unified-reset", "1737820800") // A future Unix timestamp
  402. // For Anthropic platform, calculateOpenAI429ResetTime should return nil
  403. // because it only handles OpenAI platform
  404. resetAt := svc.calculateOpenAI429ResetTime(headers)
  405. // Should return nil since there are no x-codex-* headers
  406. if resetAt != nil {
  407. t.Errorf("expected nil for Anthropic headers, got %v", resetAt)
  408. }
  409. }
  410. func TestCalculateOpenAI429ResetTime_UserProvidedScenario(t *testing.T) {
  411. // This is the exact scenario from the user:
  412. // codex_7d_used_percent: 100
  413. // codex_7d_reset_after_seconds: 384607 (约4.5天后重置)
  414. // codex_5h_used_percent: 3
  415. // codex_5h_reset_after_seconds: 17369 (约4.8小时后重置)
  416. svc := &RateLimitService{}
  417. // Simulate headers matching user's data
  418. // Note: We need to map the canonical 5h/7d back to primary/secondary
  419. // Based on typical OpenAI behavior: primary=7d (larger window), secondary=5h (smaller window)
  420. headers := http.Header{}
  421. headers.Set("x-codex-primary-used-percent", "100")
  422. headers.Set("x-codex-primary-reset-after-seconds", "384607")
  423. headers.Set("x-codex-primary-window-minutes", "10080") // 7 days = 10080 minutes
  424. headers.Set("x-codex-secondary-used-percent", "3")
  425. headers.Set("x-codex-secondary-reset-after-seconds", "17369")
  426. headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours = 300 minutes
  427. before := time.Now()
  428. resetAt := svc.calculateOpenAI429ResetTime(headers)
  429. after := time.Now()
  430. if resetAt == nil {
  431. t.Fatal("expected non-nil resetAt for user scenario")
  432. }
  433. // Should use the 7d reset time (384607 seconds) since 7d limit is exhausted (100%)
  434. expectedDuration := 384607 * time.Second
  435. minExpected := before.Add(expectedDuration)
  436. maxExpected := after.Add(expectedDuration)
  437. if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
  438. t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected)
  439. }
  440. // Verify it's approximately 4.45 days (384607 seconds)
  441. duration := resetAt.Sub(before)
  442. actualDays := duration.Hours() / 24.0
  443. // 384607 / 86400 = ~4.45 days
  444. if actualDays < 4.4 || actualDays > 4.5 {
  445. t.Errorf("expected ~4.45 days, got %.2f days", actualDays)
  446. }
  447. t.Logf("User scenario: reset_at=%v, duration=%.2f days", resetAt, actualDays)
  448. }
  449. func TestCalculateOpenAI429ResetTime_5MinFallbackWhenNoReset(t *testing.T) {
  450. // Test that we return nil when there's used_percent but no reset_after_seconds
  451. // This should cause the caller to use the default 5-minute fallback
  452. svc := &RateLimitService{}
  453. headers := http.Header{}
  454. headers.Set("x-codex-primary-used-percent", "100")
  455. // No reset_after_seconds!
  456. resetAt := svc.calculateOpenAI429ResetTime(headers)
  457. // Should return nil since there's no reset time available
  458. if resetAt != nil {
  459. t.Errorf("expected nil when no reset_after_seconds, got %v", resetAt)
  460. }
  461. }