error_policy_integration_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "io"
  6. "net/http"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // ---------------------------------------------------------------------------
  16. // Mocks (scoped to this file by naming convention)
  17. // ---------------------------------------------------------------------------
  18. // epFixedUpstream returns a fixed response for every request.
  19. type epFixedUpstream struct {
  20. statusCode int
  21. body string
  22. calls int
  23. }
  24. func (u *epFixedUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  25. u.calls++
  26. return &http.Response{
  27. StatusCode: u.statusCode,
  28. Header: http.Header{},
  29. Body: io.NopCloser(strings.NewReader(u.body)),
  30. }, nil
  31. }
  32. func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  33. return u.Do(req, proxyURL, accountID, accountConcurrency)
  34. }
  35. // epAccountRepo records SetTempUnschedulable / SetError calls.
  36. type epAccountRepo struct {
  37. mockAccountRepoForGemini
  38. tempCalls int
  39. setErrCalls int
  40. }
  41. func (r *epAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
  42. r.tempCalls++
  43. return nil
  44. }
  45. func (r *epAccountRepo) SetError(_ context.Context, _ int64, _ string) error {
  46. r.setErrCalls++
  47. return nil
  48. }
  49. // ---------------------------------------------------------------------------
  50. // Helpers
  51. // ---------------------------------------------------------------------------
  52. func saveAndSetBaseURLs(t *testing.T) {
  53. t.Helper()
  54. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  55. oldAvail := antigravity.DefaultURLAvailability
  56. antigravity.BaseURLs = []string{"https://ep-test.example"}
  57. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  58. t.Cleanup(func() {
  59. antigravity.BaseURLs = oldBaseURLs
  60. antigravity.DefaultURLAvailability = oldAvail
  61. })
  62. }
  63. func newRetryParams(account *Account, upstream HTTPUpstream, handleError func(context.Context, string, *Account, int, http.Header, []byte, string, int64, string, bool) *handleModelRateLimitResult) antigravityRetryLoopParams {
  64. return antigravityRetryLoopParams{
  65. ctx: context.Background(),
  66. prefix: "[ep-test]",
  67. account: account,
  68. accessToken: "token",
  69. action: "generateContent",
  70. body: []byte(`{"input":"test"}`),
  71. httpUpstream: upstream,
  72. requestedModel: "claude-sonnet-4-5",
  73. handleError: handleError,
  74. }
  75. }
  76. // ---------------------------------------------------------------------------
  77. // TestRetryLoop_ErrorPolicy_CustomErrorCodes
  78. // ---------------------------------------------------------------------------
  79. func TestRetryLoop_ErrorPolicy_CustomErrorCodes(t *testing.T) {
  80. tests := []struct {
  81. name string
  82. upstreamStatus int
  83. upstreamBody string
  84. customCodes []any
  85. expectHandleError int
  86. expectUpstream int
  87. expectStatusCode int
  88. }{
  89. {
  90. name: "429_in_custom_codes_matched",
  91. upstreamStatus: 429,
  92. upstreamBody: `{"error":"rate limited"}`,
  93. customCodes: []any{float64(429)},
  94. expectHandleError: 1,
  95. expectUpstream: 1,
  96. expectStatusCode: 429,
  97. },
  98. {
  99. name: "429_not_in_custom_codes_skipped",
  100. upstreamStatus: 429,
  101. upstreamBody: `{"error":"rate limited"}`,
  102. customCodes: []any{float64(500)},
  103. expectHandleError: 0,
  104. expectUpstream: 1,
  105. expectStatusCode: 500,
  106. },
  107. {
  108. name: "500_in_custom_codes_matched",
  109. upstreamStatus: 500,
  110. upstreamBody: `{"error":"internal"}`,
  111. customCodes: []any{float64(500)},
  112. expectHandleError: 1,
  113. expectUpstream: 1,
  114. expectStatusCode: 500,
  115. },
  116. {
  117. name: "500_not_in_custom_codes_skipped",
  118. upstreamStatus: 500,
  119. upstreamBody: `{"error":"internal"}`,
  120. customCodes: []any{float64(429)},
  121. expectHandleError: 0,
  122. expectUpstream: 1,
  123. expectStatusCode: 500,
  124. },
  125. }
  126. for _, tt := range tests {
  127. t.Run(tt.name, func(t *testing.T) {
  128. saveAndSetBaseURLs(t)
  129. upstream := &epFixedUpstream{statusCode: tt.upstreamStatus, body: tt.upstreamBody}
  130. repo := &epAccountRepo{}
  131. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  132. account := &Account{
  133. ID: 100,
  134. Type: AccountTypeAPIKey,
  135. Platform: PlatformAntigravity,
  136. Schedulable: true,
  137. Status: StatusActive,
  138. Concurrency: 1,
  139. Credentials: map[string]any{
  140. "custom_error_codes_enabled": true,
  141. "custom_error_codes": tt.customCodes,
  142. },
  143. }
  144. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  145. var handleErrorCount int
  146. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  147. handleErrorCount++
  148. return nil
  149. })
  150. result, err := svc.antigravityRetryLoop(p)
  151. require.NoError(t, err)
  152. require.NotNil(t, result)
  153. require.NotNil(t, result.resp)
  154. defer func() { _ = result.resp.Body.Close() }()
  155. require.Equal(t, tt.expectStatusCode, result.resp.StatusCode)
  156. require.Equal(t, tt.expectHandleError, handleErrorCount, "handleError call count")
  157. require.Equal(t, tt.expectUpstream, upstream.calls, "upstream call count")
  158. })
  159. }
  160. }
  161. // ---------------------------------------------------------------------------
  162. // TestRetryLoop_ErrorPolicy_TempUnschedulable
  163. // ---------------------------------------------------------------------------
  164. func TestRetryLoop_ErrorPolicy_TempUnschedulable(t *testing.T) {
  165. tempRulesAccount := func(rules []any) *Account {
  166. return &Account{
  167. ID: 200,
  168. Type: AccountTypeOAuth,
  169. Platform: PlatformAntigravity,
  170. Schedulable: true,
  171. Status: StatusActive,
  172. Concurrency: 1,
  173. Credentials: map[string]any{
  174. "temp_unschedulable_enabled": true,
  175. "temp_unschedulable_rules": rules,
  176. },
  177. }
  178. }
  179. overloadedRule := map[string]any{
  180. "error_code": float64(503),
  181. "keywords": []any{"overloaded"},
  182. "duration_minutes": float64(10),
  183. }
  184. rateLimitRule := map[string]any{
  185. "error_code": float64(429),
  186. "keywords": []any{"rate limited keyword"},
  187. "duration_minutes": float64(5),
  188. }
  189. t.Run("503_overloaded_matches_rule", func(t *testing.T) {
  190. saveAndSetBaseURLs(t)
  191. upstream := &epFixedUpstream{statusCode: 503, body: `overloaded`}
  192. repo := &epAccountRepo{}
  193. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  194. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  195. account := tempRulesAccount([]any{overloadedRule})
  196. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  197. t.Error("handleError should not be called for temp unschedulable")
  198. return nil
  199. })
  200. result, err := svc.antigravityRetryLoop(p)
  201. require.Nil(t, result)
  202. var switchErr *AntigravityAccountSwitchError
  203. require.ErrorAs(t, err, &switchErr)
  204. require.Equal(t, account.ID, switchErr.OriginalAccountID)
  205. require.Equal(t, 1, upstream.calls, "should not retry")
  206. })
  207. t.Run("429_rate_limited_keyword_matches_rule", func(t *testing.T) {
  208. saveAndSetBaseURLs(t)
  209. upstream := &epFixedUpstream{statusCode: 429, body: `rate limited keyword`}
  210. repo := &epAccountRepo{}
  211. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  212. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  213. account := tempRulesAccount([]any{rateLimitRule})
  214. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  215. t.Error("handleError should not be called for temp unschedulable")
  216. return nil
  217. })
  218. result, err := svc.antigravityRetryLoop(p)
  219. require.Nil(t, result)
  220. var switchErr *AntigravityAccountSwitchError
  221. require.ErrorAs(t, err, &switchErr)
  222. require.Equal(t, account.ID, switchErr.OriginalAccountID)
  223. require.Equal(t, 1, upstream.calls, "should not retry")
  224. })
  225. t.Run("503_body_no_match_continues_default_retry", func(t *testing.T) {
  226. saveAndSetBaseURLs(t)
  227. upstream := &epFixedUpstream{statusCode: 503, body: `random`}
  228. repo := &epAccountRepo{}
  229. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  230. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  231. account := tempRulesAccount([]any{overloadedRule})
  232. // Use a short-lived context: the backoff sleep (~1s) will be
  233. // interrupted, proving the code entered the default retry path
  234. // instead of breaking early via error policy.
  235. ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
  236. defer cancel()
  237. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  238. return nil
  239. })
  240. p.ctx = ctx
  241. result, err := svc.antigravityRetryLoop(p)
  242. // Context cancellation during backoff proves default retry was entered
  243. require.Nil(t, result)
  244. require.ErrorIs(t, err, context.DeadlineExceeded)
  245. require.GreaterOrEqual(t, upstream.calls, 1, "should have called upstream at least once")
  246. })
  247. }
  248. // ---------------------------------------------------------------------------
  249. // TestRetryLoop_ErrorPolicy_NilRateLimitService
  250. // ---------------------------------------------------------------------------
  251. func TestRetryLoop_ErrorPolicy_NilRateLimitService(t *testing.T) {
  252. saveAndSetBaseURLs(t)
  253. upstream := &epFixedUpstream{statusCode: 429, body: `{"error":"rate limited"}`}
  254. // rateLimitService is nil — must not panic
  255. svc := &AntigravityGatewayService{rateLimitService: nil}
  256. account := &Account{
  257. ID: 300,
  258. Type: AccountTypeOAuth,
  259. Platform: PlatformAntigravity,
  260. Schedulable: true,
  261. Status: StatusActive,
  262. Concurrency: 1,
  263. }
  264. ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
  265. defer cancel()
  266. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  267. return nil
  268. })
  269. p.ctx = ctx
  270. // Should not panic; enters the default retry path (eventually times out)
  271. result, err := svc.antigravityRetryLoop(p)
  272. require.Nil(t, result)
  273. require.ErrorIs(t, err, context.DeadlineExceeded)
  274. require.GreaterOrEqual(t, upstream.calls, 1)
  275. }
  276. // ---------------------------------------------------------------------------
  277. // TestRetryLoop_ErrorPolicy_NoPolicy_OriginalBehavior
  278. // ---------------------------------------------------------------------------
  279. func TestRetryLoop_ErrorPolicy_NoPolicy_OriginalBehavior(t *testing.T) {
  280. saveAndSetBaseURLs(t)
  281. upstream := &epFixedUpstream{statusCode: 429, body: `{"error":"rate limited"}`}
  282. repo := &epAccountRepo{}
  283. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  284. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  285. // Plain OAuth account with no error policy configured
  286. account := &Account{
  287. ID: 400,
  288. Type: AccountTypeOAuth,
  289. Platform: PlatformAntigravity,
  290. Schedulable: true,
  291. Status: StatusActive,
  292. Concurrency: 1,
  293. }
  294. var handleErrorCount int
  295. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  296. handleErrorCount++
  297. return nil
  298. })
  299. result, err := svc.antigravityRetryLoop(p)
  300. require.NoError(t, err)
  301. require.NotNil(t, result)
  302. require.NotNil(t, result.resp)
  303. defer func() { _ = result.resp.Body.Close() }()
  304. require.Equal(t, http.StatusTooManyRequests, result.resp.StatusCode)
  305. require.Equal(t, antigravityMaxRetries, upstream.calls, "should exhaust all retries")
  306. require.Equal(t, 1, handleErrorCount, "handleError should be called once after retries exhausted")
  307. }
  308. // ---------------------------------------------------------------------------
  309. // epTrackingRepo — records SetRateLimited / SetError calls for verification.
  310. // ---------------------------------------------------------------------------
  311. type epTrackingRepo struct {
  312. mockAccountRepoForGemini
  313. rateLimitedCalls int
  314. rateLimitedID int64
  315. setErrCalls int
  316. setErrID int64
  317. tempCalls int
  318. }
  319. func (r *epTrackingRepo) SetRateLimited(_ context.Context, id int64, _ time.Time) error {
  320. r.rateLimitedCalls++
  321. r.rateLimitedID = id
  322. return nil
  323. }
  324. func (r *epTrackingRepo) SetError(_ context.Context, id int64, _ string) error {
  325. r.setErrCalls++
  326. r.setErrID = id
  327. return nil
  328. }
  329. func (r *epTrackingRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
  330. r.tempCalls++
  331. return nil
  332. }
  333. // ---------------------------------------------------------------------------
  334. // TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit
  335. //
  336. // 核心场景:自定义错误码设为 [599](一个不会真正出现的错误码),
  337. // 当上游返回 429/500/503/401 时:
  338. // - 返回给客户端的状态码必须是 500(而不是透传原始状态码)
  339. // - 不调用 SetRateLimited(不进入限流状态)
  340. // - 不调用 SetError(不停止调度)
  341. // - 不调用 handleError
  342. // ---------------------------------------------------------------------------
  343. func TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit(t *testing.T) {
  344. errorCodes := []int{429, 500, 503, 401, 403}
  345. for _, upstreamStatus := range errorCodes {
  346. t.Run(http.StatusText(upstreamStatus), func(t *testing.T) {
  347. saveAndSetBaseURLs(t)
  348. upstream := &epFixedUpstream{
  349. statusCode: upstreamStatus,
  350. body: `{"error":"some upstream error"}`,
  351. }
  352. repo := &epTrackingRepo{}
  353. rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  354. svc := &AntigravityGatewayService{rateLimitService: rlSvc}
  355. account := &Account{
  356. ID: 500,
  357. Type: AccountTypeAPIKey,
  358. Platform: PlatformAntigravity,
  359. Schedulable: true,
  360. Status: StatusActive,
  361. Concurrency: 1,
  362. Credentials: map[string]any{
  363. "custom_error_codes_enabled": true,
  364. "custom_error_codes": []any{float64(599)},
  365. },
  366. }
  367. var handleErrorCount int
  368. p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult {
  369. handleErrorCount++
  370. return nil
  371. })
  372. result, err := svc.antigravityRetryLoop(p)
  373. // 不应返回 error(Skipped 不触发账号切换)
  374. require.NoError(t, err, "should not return error")
  375. require.NotNil(t, result, "result should not be nil")
  376. require.NotNil(t, result.resp, "response should not be nil")
  377. defer func() { _ = result.resp.Body.Close() }()
  378. // 状态码必须是 500(不透传原始状态码)
  379. require.Equal(t, http.StatusInternalServerError, result.resp.StatusCode,
  380. "skipped error should return 500, not %d", upstreamStatus)
  381. // 不调用 handleError
  382. require.Equal(t, 0, handleErrorCount,
  383. "handleError should NOT be called for skipped errors")
  384. // 不标记限流
  385. require.Equal(t, 0, repo.rateLimitedCalls,
  386. "SetRateLimited should NOT be called for skipped errors")
  387. // 不停止调度
  388. require.Equal(t, 0, repo.setErrCalls,
  389. "SetError should NOT be called for skipped errors")
  390. // 只调用一次上游(不重试)
  391. require.Equal(t, 1, upstream.calls,
  392. "should call upstream exactly once (no retry)")
  393. })
  394. }
  395. }