error_policy_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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. // ---------------------------------------------------------------------------
  12. // TestCheckErrorPolicy — 6 table-driven cases for the pure logic function
  13. // ---------------------------------------------------------------------------
  14. func TestCheckErrorPolicy(t *testing.T) {
  15. tests := []struct {
  16. name string
  17. account *Account
  18. statusCode int
  19. body []byte
  20. expected ErrorPolicyResult
  21. }{
  22. {
  23. name: "no_policy_oauth_returns_none",
  24. account: &Account{
  25. ID: 1,
  26. Type: AccountTypeOAuth,
  27. Platform: PlatformAntigravity,
  28. // no custom error codes, no temp rules
  29. },
  30. statusCode: 500,
  31. body: []byte(`"error"`),
  32. expected: ErrorPolicyNone,
  33. },
  34. {
  35. name: "custom_error_codes_hit_returns_matched",
  36. account: &Account{
  37. ID: 2,
  38. Type: AccountTypeAPIKey,
  39. Platform: PlatformAntigravity,
  40. Credentials: map[string]any{
  41. "custom_error_codes_enabled": true,
  42. "custom_error_codes": []any{float64(429), float64(500)},
  43. },
  44. },
  45. statusCode: 500,
  46. body: []byte(`"error"`),
  47. expected: ErrorPolicyMatched,
  48. },
  49. {
  50. name: "custom_error_codes_miss_returns_skipped",
  51. account: &Account{
  52. ID: 3,
  53. Type: AccountTypeAPIKey,
  54. Platform: PlatformAntigravity,
  55. Credentials: map[string]any{
  56. "custom_error_codes_enabled": true,
  57. "custom_error_codes": []any{float64(429), float64(500)},
  58. },
  59. },
  60. statusCode: 503,
  61. body: []byte(`"error"`),
  62. expected: ErrorPolicySkipped,
  63. },
  64. {
  65. name: "custom_error_codes_excluding_529_skip_global_cooldown",
  66. account: &Account{
  67. ID: 33,
  68. Type: AccountTypeAPIKey,
  69. Platform: PlatformOpenAI,
  70. Credentials: map[string]any{
  71. "custom_error_codes_enabled": true,
  72. "custom_error_codes": []any{float64(429)},
  73. },
  74. },
  75. statusCode: 529,
  76. body: []byte(`{"error":{"message":"overloaded"}}`),
  77. expected: ErrorPolicySkipped,
  78. },
  79. {
  80. name: "pool_mode_skips_global_529_cooldown",
  81. account: &Account{
  82. ID: 34,
  83. Type: AccountTypeAPIKey,
  84. Platform: PlatformOpenAI,
  85. Credentials: map[string]any{
  86. "pool_mode": true,
  87. },
  88. },
  89. statusCode: 529,
  90. body: []byte(`{"error":{"message":"overloaded"}}`),
  91. expected: ErrorPolicySkipped,
  92. },
  93. {
  94. name: "ordinary_account_uses_global_529_cooldown",
  95. account: &Account{
  96. ID: 35,
  97. Type: AccountTypeAPIKey,
  98. Platform: PlatformOpenAI,
  99. },
  100. statusCode: 529,
  101. body: []byte(`{"error":{"message":"overloaded"}}`),
  102. expected: ErrorPolicyMatched,
  103. },
  104. {
  105. name: "custom_error_codes_including_529_take_precedence",
  106. account: &Account{
  107. ID: 36,
  108. Type: AccountTypeAPIKey,
  109. Platform: PlatformOpenAI,
  110. Credentials: map[string]any{
  111. "custom_error_codes_enabled": true,
  112. "custom_error_codes": []any{float64(529)},
  113. },
  114. },
  115. statusCode: 529,
  116. body: []byte(`{"error":{"message":"overloaded"}}`),
  117. expected: ErrorPolicyMatched,
  118. },
  119. {
  120. name: "temp_unschedulable_hit_returns_temp_unscheduled",
  121. account: &Account{
  122. ID: 4,
  123. Type: AccountTypeOAuth,
  124. Platform: PlatformAntigravity,
  125. Credentials: map[string]any{
  126. "temp_unschedulable_enabled": true,
  127. "temp_unschedulable_rules": []any{
  128. map[string]any{
  129. "error_code": float64(503),
  130. "keywords": []any{"overloaded"},
  131. "duration_minutes": float64(10),
  132. "description": "overloaded rule",
  133. },
  134. },
  135. },
  136. },
  137. statusCode: 503,
  138. body: []byte(`overloaded service`),
  139. expected: ErrorPolicyTempUnscheduled,
  140. },
  141. {
  142. name: "temp_unschedulable_401_first_hit_returns_temp_unscheduled",
  143. account: &Account{
  144. ID: 14,
  145. Type: AccountTypeOAuth,
  146. Platform: PlatformAntigravity,
  147. Credentials: map[string]any{
  148. "temp_unschedulable_enabled": true,
  149. "temp_unschedulable_rules": []any{
  150. map[string]any{
  151. "error_code": float64(401),
  152. "keywords": []any{"unauthorized"},
  153. "duration_minutes": float64(10),
  154. },
  155. },
  156. },
  157. },
  158. statusCode: 401,
  159. body: []byte(`unauthorized`),
  160. expected: ErrorPolicyTempUnscheduled,
  161. },
  162. {
  163. // Antigravity 401 不走升级逻辑(由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制),
  164. // second hit 仍然返回 TempUnscheduled。
  165. name: "temp_unschedulable_401_second_hit_antigravity_stays_temp",
  166. account: &Account{
  167. ID: 15,
  168. Type: AccountTypeOAuth,
  169. Platform: PlatformAntigravity,
  170. TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`,
  171. Credentials: map[string]any{
  172. "temp_unschedulable_enabled": true,
  173. "temp_unschedulable_rules": []any{
  174. map[string]any{
  175. "error_code": float64(401),
  176. "keywords": []any{"unauthorized"},
  177. "duration_minutes": float64(10),
  178. },
  179. },
  180. },
  181. },
  182. statusCode: 401,
  183. body: []byte(`unauthorized`),
  184. expected: ErrorPolicyTempUnscheduled,
  185. },
  186. {
  187. name: "temp_unschedulable_body_miss_returns_none",
  188. account: &Account{
  189. ID: 5,
  190. Type: AccountTypeOAuth,
  191. Platform: PlatformAntigravity,
  192. Credentials: map[string]any{
  193. "temp_unschedulable_enabled": true,
  194. "temp_unschedulable_rules": []any{
  195. map[string]any{
  196. "error_code": float64(503),
  197. "keywords": []any{"overloaded"},
  198. "duration_minutes": float64(10),
  199. "description": "overloaded rule",
  200. },
  201. },
  202. },
  203. },
  204. statusCode: 503,
  205. body: []byte(`random msg`),
  206. expected: ErrorPolicyNone,
  207. },
  208. {
  209. name: "custom_error_codes_override_temp_unschedulable",
  210. account: &Account{
  211. ID: 6,
  212. Type: AccountTypeAPIKey,
  213. Platform: PlatformAntigravity,
  214. Credentials: map[string]any{
  215. "custom_error_codes_enabled": true,
  216. "custom_error_codes": []any{float64(503)},
  217. "temp_unschedulable_enabled": true,
  218. "temp_unschedulable_rules": []any{
  219. map[string]any{
  220. "error_code": float64(503),
  221. "keywords": []any{"overloaded"},
  222. "duration_minutes": float64(10),
  223. "description": "overloaded rule",
  224. },
  225. },
  226. },
  227. },
  228. statusCode: 503,
  229. body: []byte(`overloaded`),
  230. expected: ErrorPolicyMatched, // custom codes take precedence
  231. },
  232. {
  233. name: "pool_mode_custom_error_codes_hit_returns_matched",
  234. account: &Account{
  235. ID: 7,
  236. Type: AccountTypeAPIKey,
  237. Platform: PlatformOpenAI,
  238. Credentials: map[string]any{
  239. "pool_mode": true,
  240. "custom_error_codes_enabled": true,
  241. "custom_error_codes": []any{float64(401), float64(403)},
  242. },
  243. },
  244. statusCode: 401,
  245. body: []byte(`unauthorized`),
  246. expected: ErrorPolicyMatched,
  247. },
  248. {
  249. name: "pool_mode_without_custom_error_codes_returns_skipped",
  250. account: &Account{
  251. ID: 8,
  252. Type: AccountTypeAPIKey,
  253. Platform: PlatformOpenAI,
  254. Credentials: map[string]any{
  255. "pool_mode": true,
  256. },
  257. },
  258. statusCode: 401,
  259. body: []byte(`unauthorized`),
  260. expected: ErrorPolicySkipped,
  261. },
  262. {
  263. name: "pool_mode_temp_unschedulable_hit_returns_temp_unscheduled",
  264. account: &Account{
  265. ID: 9,
  266. Type: AccountTypeAPIKey,
  267. Platform: PlatformOpenAI,
  268. Credentials: map[string]any{
  269. "pool_mode": true,
  270. "temp_unschedulable_enabled": true,
  271. "temp_unschedulable_rules": []any{
  272. map[string]any{
  273. "error_code": float64(http.StatusServiceUnavailable),
  274. "keywords": []any{"unavailable"},
  275. "duration_minutes": float64(30),
  276. },
  277. },
  278. },
  279. },
  280. statusCode: http.StatusServiceUnavailable,
  281. body: []byte(`Service temporarily unavailable`),
  282. expected: ErrorPolicyTempUnscheduled,
  283. },
  284. {
  285. name: "pool_mode_temp_unschedulable_miss_returns_skipped",
  286. account: &Account{
  287. ID: 10,
  288. Type: AccountTypeAPIKey,
  289. Platform: PlatformOpenAI,
  290. Credentials: map[string]any{
  291. "pool_mode": true,
  292. "temp_unschedulable_enabled": true,
  293. "temp_unschedulable_rules": []any{
  294. map[string]any{
  295. "error_code": float64(http.StatusServiceUnavailable),
  296. "keywords": []any{"maintenance"},
  297. "duration_minutes": float64(30),
  298. },
  299. },
  300. },
  301. },
  302. statusCode: http.StatusServiceUnavailable,
  303. body: []byte(`Service temporarily unavailable`),
  304. expected: ErrorPolicySkipped,
  305. },
  306. }
  307. for _, tt := range tests {
  308. t.Run(tt.name, func(t *testing.T) {
  309. repo := &errorPolicyRepoStub{}
  310. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  311. result := svc.CheckErrorPolicy(context.Background(), tt.account, tt.statusCode, tt.body)
  312. require.Equal(t, tt.expected, result, "unexpected ErrorPolicyResult")
  313. })
  314. }
  315. }
  316. func TestHandleUpstreamError_PoolModePolicies(t *testing.T) {
  317. t.Run("pool_mode_without_custom_error_codes_still_skips", func(t *testing.T) {
  318. repo := &errorPolicyRepoStub{}
  319. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  320. account := &Account{
  321. ID: 30,
  322. Type: AccountTypeAPIKey,
  323. Platform: PlatformOpenAI,
  324. Credentials: map[string]any{
  325. "pool_mode": true,
  326. },
  327. }
  328. shouldDisable := svc.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
  329. require.False(t, shouldDisable)
  330. require.Equal(t, 0, repo.setErrCalls)
  331. require.Equal(t, 0, repo.tempCalls)
  332. })
  333. t.Run("pool_mode_with_custom_error_codes_uses_local_error_policy", func(t *testing.T) {
  334. repo := &errorPolicyRepoStub{}
  335. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  336. account := &Account{
  337. ID: 31,
  338. Type: AccountTypeAPIKey,
  339. Platform: PlatformOpenAI,
  340. Credentials: map[string]any{
  341. "pool_mode": true,
  342. "custom_error_codes_enabled": true,
  343. "custom_error_codes": []any{float64(401)},
  344. },
  345. }
  346. shouldDisable := svc.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
  347. require.True(t, shouldDisable)
  348. require.Equal(t, 1, repo.setErrCalls)
  349. require.Equal(t, 0, repo.tempCalls)
  350. })
  351. t.Run("pool_mode_explicit_temp_rule_stops_scheduling", func(t *testing.T) {
  352. repo := &errorPolicyRepoStub{}
  353. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  354. account := &Account{
  355. ID: 32,
  356. Type: AccountTypeAPIKey,
  357. Platform: PlatformOpenAI,
  358. Credentials: map[string]any{
  359. "pool_mode": true,
  360. "temp_unschedulable_enabled": true,
  361. "temp_unschedulable_rules": []any{
  362. map[string]any{
  363. "error_code": float64(http.StatusServiceUnavailable),
  364. "keywords": []any{"unavailable"},
  365. "duration_minutes": float64(30),
  366. },
  367. },
  368. },
  369. }
  370. shouldDisable := svc.HandleUpstreamError(
  371. context.Background(),
  372. account,
  373. http.StatusServiceUnavailable,
  374. http.Header{},
  375. []byte("Service temporarily unavailable"),
  376. )
  377. require.True(t, shouldDisable)
  378. require.Equal(t, 0, repo.setErrCalls)
  379. require.Equal(t, 1, repo.tempCalls)
  380. })
  381. t.Run("pool_mode_temp_rule_miss_still_skips", func(t *testing.T) {
  382. repo := &errorPolicyRepoStub{}
  383. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  384. account := &Account{
  385. ID: 33,
  386. Type: AccountTypeAPIKey,
  387. Platform: PlatformOpenAI,
  388. Credentials: map[string]any{
  389. "pool_mode": true,
  390. "temp_unschedulable_enabled": true,
  391. "temp_unschedulable_rules": []any{
  392. map[string]any{
  393. "error_code": float64(http.StatusServiceUnavailable),
  394. "keywords": []any{"maintenance"},
  395. "duration_minutes": float64(30),
  396. },
  397. },
  398. },
  399. }
  400. shouldDisable := svc.HandleUpstreamError(
  401. context.Background(),
  402. account,
  403. http.StatusServiceUnavailable,
  404. http.Header{},
  405. []byte("Service temporarily unavailable"),
  406. )
  407. require.False(t, shouldDisable)
  408. require.Equal(t, 0, repo.setErrCalls)
  409. require.Equal(t, 0, repo.tempCalls)
  410. })
  411. }
  412. // ---------------------------------------------------------------------------
  413. // TestApplyErrorPolicy — 4 table-driven cases for the wrapper method
  414. // ---------------------------------------------------------------------------
  415. func TestApplyErrorPolicy(t *testing.T) {
  416. tests := []struct {
  417. name string
  418. account *Account
  419. statusCode int
  420. body []byte
  421. expectedHandled bool
  422. expectedStatus int // expected outStatus
  423. expectedSwitchErr bool // expect *AntigravityAccountSwitchError
  424. handleErrorCalls int
  425. }{
  426. {
  427. name: "none_not_handled",
  428. account: &Account{
  429. ID: 10,
  430. Type: AccountTypeOAuth,
  431. Platform: PlatformAntigravity,
  432. },
  433. statusCode: 500,
  434. body: []byte(`"error"`),
  435. expectedHandled: false,
  436. expectedStatus: 500, // passthrough
  437. handleErrorCalls: 0,
  438. },
  439. {
  440. name: "skipped_handled_no_handleError",
  441. account: &Account{
  442. ID: 11,
  443. Type: AccountTypeAPIKey,
  444. Platform: PlatformAntigravity,
  445. Credentials: map[string]any{
  446. "custom_error_codes_enabled": true,
  447. "custom_error_codes": []any{float64(429)},
  448. },
  449. },
  450. statusCode: 500, // not in custom codes
  451. body: []byte(`"error"`),
  452. expectedHandled: true,
  453. expectedStatus: http.StatusInternalServerError, // skipped → 500
  454. handleErrorCalls: 0,
  455. },
  456. {
  457. name: "matched_handled_calls_handleError",
  458. account: &Account{
  459. ID: 12,
  460. Type: AccountTypeAPIKey,
  461. Platform: PlatformAntigravity,
  462. Credentials: map[string]any{
  463. "custom_error_codes_enabled": true,
  464. "custom_error_codes": []any{float64(500)},
  465. },
  466. },
  467. statusCode: 500,
  468. body: []byte(`"error"`),
  469. expectedHandled: true,
  470. expectedStatus: 500, // matched → original status
  471. handleErrorCalls: 1,
  472. },
  473. {
  474. name: "temp_unscheduled_returns_switch_error",
  475. account: &Account{
  476. ID: 13,
  477. Type: AccountTypeOAuth,
  478. Platform: PlatformAntigravity,
  479. Credentials: map[string]any{
  480. "model_mapping": map[string]any{
  481. "claude-sonnet-4-5": "claude-sonnet-4-5",
  482. },
  483. "temp_unschedulable_enabled": true,
  484. "temp_unschedulable_rules": []any{
  485. map[string]any{
  486. "error_code": float64(503),
  487. "keywords": []any{"overloaded"},
  488. "duration_minutes": float64(10),
  489. },
  490. },
  491. },
  492. },
  493. statusCode: 503,
  494. body: []byte(`overloaded`),
  495. expectedHandled: true,
  496. expectedStatus: 503, // temp_unscheduled → original status
  497. expectedSwitchErr: true,
  498. handleErrorCalls: 0,
  499. },
  500. }
  501. for _, tt := range tests {
  502. t.Run(tt.name, func(t *testing.T) {
  503. repo := &errorPolicyRepoStub{}
  504. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  505. svc := &AntigravityGatewayService{
  506. rateLimitService: rlSvc,
  507. }
  508. var handleErrorCount int
  509. p := antigravityRetryLoopParams{
  510. ctx: context.Background(),
  511. prefix: "[test]",
  512. account: tt.account,
  513. requestedModel: "claude-sonnet-4-5",
  514. handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
  515. handleErrorCount++
  516. return nil
  517. },
  518. isStickySession: true,
  519. }
  520. handled, outStatus, retErr := svc.applyErrorPolicy(p, tt.statusCode, http.Header{}, tt.body)
  521. require.Equal(t, tt.expectedHandled, handled, "handled mismatch")
  522. require.Equal(t, tt.expectedStatus, outStatus, "outStatus mismatch")
  523. require.Equal(t, tt.handleErrorCalls, handleErrorCount, "handleError call count mismatch")
  524. if tt.expectedSwitchErr {
  525. var switchErr *AntigravityAccountSwitchError
  526. require.ErrorAs(t, retErr, &switchErr)
  527. require.Equal(t, tt.account.ID, switchErr.OriginalAccountID)
  528. require.Zero(t, repo.tempCalls)
  529. require.Len(t, repo.modelRateLimitCalls, 1)
  530. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].scope)
  531. } else {
  532. require.NoError(t, retErr)
  533. }
  534. })
  535. }
  536. }
  537. func TestApplyErrorPolicy_GeminiRateLimitBypassesCustomSkip(t *testing.T) {
  538. repo := &stubAntigravityAccountRepo{}
  539. cache := &stubSmartRetryCache{}
  540. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  541. svc := &AntigravityGatewayService{
  542. rateLimitService: rlSvc,
  543. accountRepo: repo,
  544. cache: cache,
  545. }
  546. account := &Account{
  547. ID: 31,
  548. Type: AccountTypeAPIKey,
  549. Platform: PlatformAntigravity,
  550. Credentials: map[string]any{
  551. "custom_error_codes_enabled": true,
  552. "custom_error_codes": []any{float64(500)},
  553. },
  554. }
  555. body := []byte(`{
  556. "error": {
  557. "status": "RESOURCE_EXHAUSTED",
  558. "details": [
  559. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
  560. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
  561. ]
  562. }
  563. }`)
  564. p := antigravityRetryLoopParams{
  565. ctx: context.Background(),
  566. prefix: "[test]",
  567. account: account,
  568. accountRepo: repo,
  569. groupID: 42,
  570. sessionHash: "gemini:sticky",
  571. handleError: func(context.Context, string, *Account, int, http.Header, []byte, string, int64, string, bool) *handleModelRateLimitResult {
  572. t.Fatal("model rate limit should be handled before custom error fallback")
  573. return nil
  574. },
  575. }
  576. handled, outStatus, retErr := svc.applyErrorPolicy(p, http.StatusTooManyRequests, http.Header{}, body)
  577. require.True(t, handled)
  578. require.Equal(t, http.StatusTooManyRequests, outStatus)
  579. require.NoError(t, retErr)
  580. require.Len(t, repo.modelRateLimitCalls, 2)
  581. require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
  582. require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
  583. require.Len(t, cache.deleteCalls, 1)
  584. require.Equal(t, int64(42), cache.deleteCalls[0].groupID)
  585. require.Equal(t, "gemini:sticky", cache.deleteCalls[0].sessionHash)
  586. }
  587. // ---------------------------------------------------------------------------
  588. // errorPolicyRepoStub — minimal AccountRepository stub for error policy tests
  589. // ---------------------------------------------------------------------------
  590. type errorPolicyRepoStub struct {
  591. mockAccountRepoForGemini
  592. tempCalls int
  593. setErrCalls int
  594. lastErrorMsg string
  595. modelRateLimitCalls []modelNotFoundRateLimitCall
  596. }
  597. func (r *errorPolicyRepoStub) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
  598. r.tempCalls++
  599. return nil
  600. }
  601. func (r *errorPolicyRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error {
  602. r.setErrCalls++
  603. r.lastErrorMsg = errorMsg
  604. return nil
  605. }
  606. func (r *errorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, resetAt time.Time, reason ...string) error {
  607. call := modelNotFoundRateLimitCall{accountID: id, scope: scope, resetAt: resetAt}
  608. if len(reason) > 0 {
  609. call.reason = reason[0]
  610. }
  611. r.modelRateLimitCalls = append(r.modelRateLimitCalls, call)
  612. return nil
  613. }