ratelimit_session_window_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  9. )
  10. // sessionWindowMockRepo is a minimal AccountRepository mock that records calls
  11. // made by UpdateSessionWindow. Unrelated methods panic if invoked.
  12. type sessionWindowMockRepo struct {
  13. // captured calls
  14. sessionWindowCalls []swCall
  15. updateExtraCalls []ueCall
  16. clearRateLimitIDs []int64
  17. }
  18. var _ AccountRepository = (*sessionWindowMockRepo)(nil)
  19. type swCall struct {
  20. ID int64
  21. Start *time.Time
  22. End *time.Time
  23. Status string
  24. }
  25. type ueCall struct {
  26. ID int64
  27. Updates map[string]any
  28. }
  29. func (m *sessionWindowMockRepo) UpdateSessionWindow(_ context.Context, id int64, start, end *time.Time, status string) error {
  30. m.sessionWindowCalls = append(m.sessionWindowCalls, swCall{ID: id, Start: start, End: end, Status: status})
  31. return nil
  32. }
  33. func (m *sessionWindowMockRepo) UpdateSessionWindowEnd(_ context.Context, _ int64, _ time.Time) error {
  34. return nil
  35. }
  36. func (m *sessionWindowMockRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  37. m.updateExtraCalls = append(m.updateExtraCalls, ueCall{ID: id, Updates: updates})
  38. return nil
  39. }
  40. func (m *sessionWindowMockRepo) ClearRateLimit(_ context.Context, id int64) error {
  41. m.clearRateLimitIDs = append(m.clearRateLimitIDs, id)
  42. return nil
  43. }
  44. func (m *sessionWindowMockRepo) ClearAntigravityQuotaScopes(_ context.Context, _ int64) error {
  45. return nil
  46. }
  47. func (m *sessionWindowMockRepo) ClearModelRateLimits(_ context.Context, _ int64) error {
  48. return nil
  49. }
  50. func (m *sessionWindowMockRepo) ClearTempUnschedulable(_ context.Context, _ int64) error {
  51. return nil
  52. }
  53. // --- Unused interface methods (panic on unexpected call) ---
  54. func (m *sessionWindowMockRepo) Create(context.Context, *Account) error { panic("unexpected") }
  55. func (m *sessionWindowMockRepo) GetByID(context.Context, int64) (*Account, error) {
  56. panic("unexpected")
  57. }
  58. func (m *sessionWindowMockRepo) GetByIDs(context.Context, []int64) ([]*Account, error) {
  59. panic("unexpected")
  60. }
  61. func (m *sessionWindowMockRepo) ExistsByID(context.Context, int64) (bool, error) {
  62. panic("unexpected")
  63. }
  64. func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Account, error) {
  65. panic("unexpected")
  66. }
  67. func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
  68. panic("unexpected")
  69. }
  70. func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
  71. panic("unexpected")
  72. }
  73. func (m *sessionWindowMockRepo) Update(context.Context, *Account) error { panic("unexpected") }
  74. func (m *sessionWindowMockRepo) Delete(context.Context, int64) error { panic("unexpected") }
  75. func (m *sessionWindowMockRepo) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
  76. panic("unexpected")
  77. }
  78. func (m *sessionWindowMockRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64, string) ([]Account, *pagination.PaginationResult, error) {
  79. panic("unexpected")
  80. }
  81. func (m *sessionWindowMockRepo) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) {
  82. panic("unexpected")
  83. }
  84. func (m *sessionWindowMockRepo) ListByGroup(context.Context, int64) ([]Account, error) {
  85. panic("unexpected")
  86. }
  87. func (m *sessionWindowMockRepo) ListActive(context.Context) ([]Account, error) {
  88. panic("unexpected")
  89. }
  90. func (m *sessionWindowMockRepo) ListOAuthRefreshCandidates(context.Context) ([]Account, error) {
  91. panic("unexpected")
  92. }
  93. func (m *sessionWindowMockRepo) ListByPlatform(context.Context, string) ([]Account, error) {
  94. panic("unexpected")
  95. }
  96. func (m *sessionWindowMockRepo) UpdateLastUsed(context.Context, int64) error { panic("unexpected") }
  97. func (m *sessionWindowMockRepo) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error {
  98. panic("unexpected")
  99. }
  100. func (m *sessionWindowMockRepo) SetError(context.Context, int64, string) error {
  101. panic("unexpected")
  102. }
  103. func (m *sessionWindowMockRepo) ClearError(context.Context, int64) error { panic("unexpected") }
  104. func (m *sessionWindowMockRepo) SetSchedulable(context.Context, int64, bool) error {
  105. panic("unexpected")
  106. }
  107. func (m *sessionWindowMockRepo) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) {
  108. panic("unexpected")
  109. }
  110. func (m *sessionWindowMockRepo) BindGroups(context.Context, int64, []int64) error {
  111. panic("unexpected")
  112. }
  113. func (m *sessionWindowMockRepo) ListSchedulable(context.Context) ([]Account, error) {
  114. panic("unexpected")
  115. }
  116. func (m *sessionWindowMockRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
  117. panic("unexpected")
  118. }
  119. func (m *sessionWindowMockRepo) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
  120. panic("unexpected")
  121. }
  122. func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) {
  123. panic("unexpected")
  124. }
  125. func (m *sessionWindowMockRepo) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) {
  126. panic("unexpected")
  127. }
  128. func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) {
  129. panic("unexpected")
  130. }
  131. func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) {
  132. panic("unexpected")
  133. }
  134. func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) {
  135. panic("unexpected")
  136. }
  137. func (m *sessionWindowMockRepo) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]Account, error) {
  138. panic("unexpected")
  139. }
  140. func (m *sessionWindowMockRepo) SetRateLimited(context.Context, int64, time.Time) error {
  141. panic("unexpected")
  142. }
  143. func (m *sessionWindowMockRepo) SetModelRateLimit(context.Context, int64, string, time.Time, ...string) error {
  144. panic("unexpected")
  145. }
  146. func (m *sessionWindowMockRepo) SetOverloaded(context.Context, int64, time.Time) error {
  147. panic("unexpected")
  148. }
  149. func (m *sessionWindowMockRepo) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
  150. panic("unexpected")
  151. }
  152. func (m *sessionWindowMockRepo) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) {
  153. panic("unexpected")
  154. }
  155. func (m *sessionWindowMockRepo) IncrementQuotaUsed(context.Context, int64, float64) error {
  156. panic("unexpected")
  157. }
  158. func (m *sessionWindowMockRepo) ResetQuotaUsedAndClearRateLimitCooldown(context.Context, int64) error {
  159. panic("unexpected")
  160. }
  161. func (m *sessionWindowMockRepo) RevertProxyFallback(context.Context, int64) error {
  162. panic("unexpected")
  163. }
  164. func (m *sessionWindowMockRepo) ListShadowsByParent(context.Context, int64) ([]*Account, error) {
  165. panic("unexpected")
  166. }
  167. // newRateLimitServiceForTest creates a RateLimitService with the given mock repo.
  168. func newRateLimitServiceForTest(repo AccountRepository) *RateLimitService {
  169. return &RateLimitService{accountRepo: repo}
  170. }
  171. func TestUpdateSessionWindow_UsesResetHeader(t *testing.T) {
  172. // The reset header provides the real window end as a Unix timestamp.
  173. // UpdateSessionWindow should use it instead of the hour-truncated prediction.
  174. resetUnix := time.Now().Add(3 * time.Hour).Unix()
  175. wantEnd := time.Unix(resetUnix, 0)
  176. wantStart := wantEnd.Add(-5 * time.Hour)
  177. repo := &sessionWindowMockRepo{}
  178. svc := newRateLimitServiceForTest(repo)
  179. account := &Account{ID: 42} // no existing window → needInitWindow=true
  180. headers := http.Header{}
  181. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  182. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
  183. svc.UpdateSessionWindow(context.Background(), account, headers)
  184. if len(repo.sessionWindowCalls) != 1 {
  185. t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
  186. }
  187. call := repo.sessionWindowCalls[0]
  188. if call.ID != 42 {
  189. t.Errorf("expected account ID 42, got %d", call.ID)
  190. }
  191. if call.End == nil || !call.End.Equal(wantEnd) {
  192. t.Errorf("expected window end %v, got %v", wantEnd, call.End)
  193. }
  194. if call.Start == nil || !call.Start.Equal(wantStart) {
  195. t.Errorf("expected window start %v, got %v", wantStart, call.Start)
  196. }
  197. if call.Status != "allowed" {
  198. t.Errorf("expected status 'allowed', got %q", call.Status)
  199. }
  200. }
  201. func TestUpdateSessionWindow_FallbackPredictionWhenNoResetHeader(t *testing.T) {
  202. // When the reset header is absent, should fall back to hour-truncated prediction.
  203. repo := &sessionWindowMockRepo{}
  204. svc := newRateLimitServiceForTest(repo)
  205. account := &Account{ID: 10} // no existing window
  206. headers := http.Header{}
  207. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed_warning")
  208. // No anthropic-ratelimit-unified-5h-reset header
  209. // Capture now before the call to avoid hour-boundary races
  210. now := time.Now()
  211. expectedStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
  212. expectedEnd := expectedStart.Add(5 * time.Hour)
  213. svc.UpdateSessionWindow(context.Background(), account, headers)
  214. if len(repo.sessionWindowCalls) != 1 {
  215. t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
  216. }
  217. call := repo.sessionWindowCalls[0]
  218. if call.End == nil {
  219. t.Fatal("expected window end to be set (fallback prediction)")
  220. }
  221. // Fallback: start = current hour truncated, end = start + 5h
  222. if !call.End.Equal(expectedEnd) {
  223. t.Errorf("expected fallback end %v, got %v", expectedEnd, *call.End)
  224. }
  225. if call.Start == nil || !call.Start.Equal(expectedStart) {
  226. t.Errorf("expected fallback start %v, got %v", expectedStart, call.Start)
  227. }
  228. }
  229. func TestUpdateSessionWindow_CorrectsStalePrediction(t *testing.T) {
  230. // When the stored SessionWindowEnd is wrong (from a previous prediction),
  231. // and the reset header provides the real time, it should update the window.
  232. staleEnd := time.Now().Add(2 * time.Hour) // existing prediction: 2h from now
  233. realResetUnix := time.Now().Add(4 * time.Hour).Unix() // real reset: 4h from now
  234. wantEnd := time.Unix(realResetUnix, 0)
  235. repo := &sessionWindowMockRepo{}
  236. svc := newRateLimitServiceForTest(repo)
  237. account := &Account{
  238. ID: 55,
  239. SessionWindowEnd: &staleEnd,
  240. }
  241. headers := http.Header{}
  242. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  243. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
  244. svc.UpdateSessionWindow(context.Background(), account, headers)
  245. if len(repo.sessionWindowCalls) != 1 {
  246. t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
  247. }
  248. call := repo.sessionWindowCalls[0]
  249. if call.End == nil || !call.End.Equal(wantEnd) {
  250. t.Errorf("expected corrected end %v, got %v", wantEnd, call.End)
  251. }
  252. }
  253. func TestUpdateSessionWindow_NoUpdateWhenHeaderMatchesStored(t *testing.T) {
  254. // If the reset header matches the stored SessionWindowEnd, no window update needed.
  255. futureUnix := time.Now().Add(3 * time.Hour).Unix()
  256. existingEnd := time.Unix(futureUnix, 0)
  257. repo := &sessionWindowMockRepo{}
  258. svc := newRateLimitServiceForTest(repo)
  259. account := &Account{
  260. ID: 77,
  261. SessionWindowEnd: &existingEnd,
  262. }
  263. headers := http.Header{}
  264. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  265. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", futureUnix)) // same as stored
  266. svc.UpdateSessionWindow(context.Background(), account, headers)
  267. if len(repo.sessionWindowCalls) != 1 {
  268. t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
  269. }
  270. call := repo.sessionWindowCalls[0]
  271. // windowStart and windowEnd should be nil (no update needed)
  272. if call.Start != nil || call.End != nil {
  273. t.Errorf("expected nil start/end (no window change needed), got start=%v end=%v", call.Start, call.End)
  274. }
  275. // Status is still updated
  276. if call.Status != "allowed" {
  277. t.Errorf("expected status 'allowed', got %q", call.Status)
  278. }
  279. }
  280. func TestUpdateSessionWindow_ClearsUtilizationOnWindowReset(t *testing.T) {
  281. // When needInitWindow=true and window is set, utilization should be cleared.
  282. resetUnix := time.Now().Add(3 * time.Hour).Unix()
  283. repo := &sessionWindowMockRepo{}
  284. svc := newRateLimitServiceForTest(repo)
  285. account := &Account{ID: 33} // no existing window → needInitWindow=true
  286. headers := http.Header{}
  287. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  288. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
  289. headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.15")
  290. svc.UpdateSessionWindow(context.Background(), account, headers)
  291. // Should have 2 UpdateExtra calls: one to clear utilization, one to store new utilization
  292. if len(repo.updateExtraCalls) != 2 {
  293. t.Fatalf("expected 2 UpdateExtra calls, got %d", len(repo.updateExtraCalls))
  294. }
  295. // First call: clear utilization (nil value)
  296. clearCall := repo.updateExtraCalls[0]
  297. if clearCall.Updates["session_window_utilization"] != nil {
  298. t.Errorf("expected utilization cleared to nil, got %v", clearCall.Updates["session_window_utilization"])
  299. }
  300. // Second call: store new utilization
  301. storeCall := repo.updateExtraCalls[1]
  302. if val, ok := storeCall.Updates["session_window_utilization"].(float64); !ok || val != 0.15 {
  303. t.Errorf("expected utilization stored as 0.15, got %v", storeCall.Updates["session_window_utilization"])
  304. }
  305. }
  306. func TestUpdateSessionWindow_NoClearUtilizationOnCorrection(t *testing.T) {
  307. // When correcting a stale prediction (needInitWindow=false), utilization should NOT be cleared.
  308. staleEnd := time.Now().Add(2 * time.Hour)
  309. realResetUnix := time.Now().Add(4 * time.Hour).Unix()
  310. repo := &sessionWindowMockRepo{}
  311. svc := newRateLimitServiceForTest(repo)
  312. account := &Account{
  313. ID: 66,
  314. SessionWindowEnd: &staleEnd,
  315. }
  316. headers := http.Header{}
  317. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  318. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
  319. headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.30")
  320. svc.UpdateSessionWindow(context.Background(), account, headers)
  321. // Only 1 UpdateExtra call (store utilization), no clear call
  322. if len(repo.updateExtraCalls) != 1 {
  323. t.Fatalf("expected 1 UpdateExtra call (no clear), got %d", len(repo.updateExtraCalls))
  324. }
  325. if val, ok := repo.updateExtraCalls[0].Updates["session_window_utilization"].(float64); !ok || val != 0.30 {
  326. t.Errorf("expected utilization 0.30, got %v", repo.updateExtraCalls[0].Updates["session_window_utilization"])
  327. }
  328. }
  329. func TestUpdateSessionWindow_SamplesFable7dOiHeaders(t *testing.T) {
  330. // 被动采样应收集 7d_oi(Fable 专属 7d 窗口)的 utilization 和 reset。
  331. existingEnd := time.Now().Add(3 * time.Hour)
  332. resetOIUnix := time.Now().Add(80 * time.Hour).Unix()
  333. repo := &sessionWindowMockRepo{}
  334. svc := newRateLimitServiceForTest(repo)
  335. account := &Account{ID: 90, SessionWindowEnd: &existingEnd} // needInitWindow=false
  336. headers := http.Header{}
  337. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  338. headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "0.87")
  339. headers.Set("anthropic-ratelimit-unified-7d_oi-reset", fmt.Sprintf("%d", resetOIUnix))
  340. svc.UpdateSessionWindow(context.Background(), account, headers)
  341. if len(repo.updateExtraCalls) != 1 {
  342. t.Fatalf("expected 1 UpdateExtra call, got %d", len(repo.updateExtraCalls))
  343. }
  344. updates := repo.updateExtraCalls[0].Updates
  345. if val, ok := updates["passive_usage_7d_oi_utilization"].(float64); !ok || val != 0.87 {
  346. t.Errorf("expected passive_usage_7d_oi_utilization=0.87, got %v", updates["passive_usage_7d_oi_utilization"])
  347. }
  348. if val, ok := updates["passive_usage_7d_oi_reset"].(int64); !ok || val != resetOIUnix {
  349. t.Errorf("expected passive_usage_7d_oi_reset=%d, got %v", resetOIUnix, updates["passive_usage_7d_oi_reset"])
  350. }
  351. }
  352. func TestUpdateSessionWindow_ClearsFable7dOiOnWindowReset(t *testing.T) {
  353. // 5h 窗口重置时应连同清除 7d_oi 被动采样数据,与 7d 行为一致。
  354. resetUnix := time.Now().Add(3 * time.Hour).Unix()
  355. repo := &sessionWindowMockRepo{}
  356. svc := newRateLimitServiceForTest(repo)
  357. account := &Account{ID: 91} // no existing window → needInitWindow=true
  358. headers := http.Header{}
  359. headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
  360. headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
  361. svc.UpdateSessionWindow(context.Background(), account, headers)
  362. if len(repo.updateExtraCalls) != 1 {
  363. t.Fatalf("expected 1 UpdateExtra (clear) call, got %d", len(repo.updateExtraCalls))
  364. }
  365. clearUpdates := repo.updateExtraCalls[0].Updates
  366. for _, key := range []string{"passive_usage_7d_oi_utilization", "passive_usage_7d_oi_reset"} {
  367. if val, present := clearUpdates[key]; !present || val != nil {
  368. t.Errorf("expected %s cleared to nil on window reset, got present=%v val=%v", key, present, val)
  369. }
  370. }
  371. }
  372. func TestUpdateSessionWindow_NoStatusHeader(t *testing.T) {
  373. // Should return immediately if no status header.
  374. repo := &sessionWindowMockRepo{}
  375. svc := newRateLimitServiceForTest(repo)
  376. account := &Account{ID: 1}
  377. svc.UpdateSessionWindow(context.Background(), account, http.Header{})
  378. if len(repo.sessionWindowCalls) != 0 {
  379. t.Errorf("expected no calls when status header absent, got %d", len(repo.sessionWindowCalls))
  380. }
  381. }