openai_gateway_credential_failover_loop_test.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "sync"
  13. "testing"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/config"
  16. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  18. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  19. "github.com/Wei-Shaw/sub2api/internal/service"
  20. coderws "github.com/coder/websocket"
  21. "github.com/gin-gonic/gin"
  22. "github.com/stretchr/testify/require"
  23. )
  24. type grokCredentialHandlerRepo struct {
  25. service.AccountRepository
  26. mu sync.Mutex
  27. accounts []service.Account
  28. setErrorIDs []int64
  29. setTempIDs []int64
  30. rateLimitIDs []int64
  31. updateExtraIDs []int64
  32. selectionCalls int
  33. setErrorErr error
  34. setTempErr error
  35. missingOnGet map[int64]bool
  36. }
  37. func (r *grokCredentialHandlerRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]service.Account, error) {
  38. r.mu.Lock()
  39. defer r.mu.Unlock()
  40. r.selectionCalls++
  41. out := make([]service.Account, 0, len(r.accounts))
  42. for _, account := range r.accounts {
  43. if account.Platform == platform && account.IsSchedulable() {
  44. out = append(out, account)
  45. }
  46. }
  47. return out, nil
  48. }
  49. func (r *grokCredentialHandlerRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, _ int64, platform string) ([]service.Account, error) {
  50. return r.ListSchedulableByPlatform(ctx, platform)
  51. }
  52. func (r *grokCredentialHandlerRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]service.Account, error) {
  53. return r.ListSchedulableByPlatform(ctx, platform)
  54. }
  55. func (r *grokCredentialHandlerRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
  56. r.mu.Lock()
  57. defer r.mu.Unlock()
  58. if r.missingOnGet[id] {
  59. return nil, nil
  60. }
  61. for _, account := range r.accounts {
  62. if account.ID == id {
  63. copy := account
  64. copy.Credentials = cloneCredentialMap(account.Credentials)
  65. return &copy, nil
  66. }
  67. }
  68. return nil, nil
  69. }
  70. func (r *grokCredentialHandlerRepo) SetError(_ context.Context, id int64, message string) error {
  71. r.mu.Lock()
  72. defer r.mu.Unlock()
  73. r.setErrorIDs = append(r.setErrorIDs, id)
  74. if r.setErrorErr != nil {
  75. return r.setErrorErr
  76. }
  77. for i := range r.accounts {
  78. if r.accounts[i].ID == id {
  79. r.accounts[i].Status = service.StatusError
  80. r.accounts[i].Schedulable = false
  81. r.accounts[i].ErrorMessage = message
  82. }
  83. }
  84. return nil
  85. }
  86. func (r *grokCredentialHandlerRepo) SetTempUnschedulable(_ context.Context, id int64, until time.Time, _ string) error {
  87. r.mu.Lock()
  88. defer r.mu.Unlock()
  89. r.setTempIDs = append(r.setTempIDs, id)
  90. if r.setTempErr != nil {
  91. return r.setTempErr
  92. }
  93. for i := range r.accounts {
  94. if r.accounts[i].ID == id {
  95. value := until
  96. r.accounts[i].TempUnschedulableUntil = &value
  97. }
  98. }
  99. return nil
  100. }
  101. func (r *grokCredentialHandlerRepo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
  102. r.mu.Lock()
  103. defer r.mu.Unlock()
  104. r.rateLimitIDs = append(r.rateLimitIDs, id)
  105. for i := range r.accounts {
  106. if r.accounts[i].ID != id {
  107. continue
  108. }
  109. now := time.Now()
  110. r.accounts[i].RateLimitedAt = &now
  111. value := resetAt
  112. r.accounts[i].RateLimitResetAt = &value
  113. }
  114. return nil
  115. }
  116. func (r *grokCredentialHandlerRepo) SetRateLimitedIfLater(ctx context.Context, id int64, resetAt time.Time) error {
  117. r.mu.Lock()
  118. for i := range r.accounts {
  119. if r.accounts[i].ID == id && r.accounts[i].RateLimitResetAt != nil && !resetAt.After(*r.accounts[i].RateLimitResetAt) {
  120. r.mu.Unlock()
  121. return nil
  122. }
  123. }
  124. r.mu.Unlock()
  125. return r.SetRateLimited(ctx, id, resetAt)
  126. }
  127. func (r *grokCredentialHandlerRepo) SetGrokCredentialErrorIfMatch(
  128. _ context.Context,
  129. id int64,
  130. snapshot service.GrokCredentialMutationSnapshot,
  131. message string,
  132. ) (bool, error) {
  133. r.mu.Lock()
  134. defer r.mu.Unlock()
  135. for i := range r.accounts {
  136. account := &r.accounts[i]
  137. if account.ID != id || !handlerGrokCredentialSnapshotMatches(account, snapshot) {
  138. continue
  139. }
  140. r.setErrorIDs = append(r.setErrorIDs, id)
  141. if r.setErrorErr != nil {
  142. return false, r.setErrorErr
  143. }
  144. account.Status = service.StatusError
  145. account.Schedulable = false
  146. account.ErrorMessage = message
  147. return true, nil
  148. }
  149. return false, nil
  150. }
  151. func (r *grokCredentialHandlerRepo) SetGrokCredentialTempUnschedulableIfMatch(
  152. _ context.Context,
  153. id int64,
  154. snapshot service.GrokCredentialMutationSnapshot,
  155. until time.Time,
  156. _ string,
  157. ) (bool, error) {
  158. r.mu.Lock()
  159. defer r.mu.Unlock()
  160. for i := range r.accounts {
  161. account := &r.accounts[i]
  162. if account.ID != id || !handlerGrokCredentialSnapshotMatches(account, snapshot) {
  163. continue
  164. }
  165. r.setTempIDs = append(r.setTempIDs, id)
  166. if r.setTempErr != nil {
  167. return false, r.setTempErr
  168. }
  169. value := until
  170. account.TempUnschedulableUntil = &value
  171. return true, nil
  172. }
  173. return false, nil
  174. }
  175. func handlerGrokCredentialSnapshotMatches(account *service.Account, snapshot service.GrokCredentialMutationSnapshot) bool {
  176. if account == nil {
  177. return false
  178. }
  179. credentialsJSON, err := json.Marshal(account.Credentials)
  180. return err == nil && account.IsGrokOAuth() && account.IsSchedulable() && string(credentialsJSON) == snapshot.CredentialsJSON &&
  181. handlerGrokCredentialProxyIDsEqual(account.ProxyID, snapshot.ProxyID)
  182. }
  183. func handlerGrokCredentialProxyIDsEqual(left, right *int64) bool {
  184. if left == nil || right == nil {
  185. return left == nil && right == nil
  186. }
  187. return *left == *right
  188. }
  189. func (r *grokCredentialHandlerRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  190. r.mu.Lock()
  191. defer r.mu.Unlock()
  192. r.updateExtraIDs = append(r.updateExtraIDs, id)
  193. for i := range r.accounts {
  194. if r.accounts[i].ID != id {
  195. continue
  196. }
  197. if r.accounts[i].Extra == nil {
  198. r.accounts[i].Extra = map[string]any{}
  199. }
  200. for key, value := range updates {
  201. r.accounts[i].Extra[key] = value
  202. }
  203. }
  204. return nil
  205. }
  206. func (r *grokCredentialHandlerRepo) errorIDs() []int64 {
  207. r.mu.Lock()
  208. defer r.mu.Unlock()
  209. return append([]int64(nil), r.setErrorIDs...)
  210. }
  211. func (r *grokCredentialHandlerRepo) selectorCalls() int {
  212. r.mu.Lock()
  213. defer r.mu.Unlock()
  214. return r.selectionCalls
  215. }
  216. func (r *grokCredentialHandlerRepo) rateLimitedAccountIDs() []int64 {
  217. r.mu.Lock()
  218. defer r.mu.Unlock()
  219. return append([]int64(nil), r.rateLimitIDs...)
  220. }
  221. type grokCredentialHandlerTokenCache struct {
  222. service.GrokTokenCache
  223. mu sync.Mutex
  224. deleteErr error
  225. }
  226. func (c *grokCredentialHandlerTokenCache) GetAccessToken(context.Context, string) (string, error) {
  227. return "", errors.New("not cached")
  228. }
  229. func (c *grokCredentialHandlerTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
  230. return nil
  231. }
  232. func (c *grokCredentialHandlerTokenCache) DeleteAccessToken(context.Context, string) error {
  233. c.mu.Lock()
  234. defer c.mu.Unlock()
  235. return c.deleteErr
  236. }
  237. func (c *grokCredentialHandlerTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
  238. return true, nil
  239. }
  240. func (c *grokCredentialHandlerTokenCache) ReleaseRefreshLock(context.Context, string) error {
  241. return nil
  242. }
  243. func cloneCredentialMap(source map[string]any) map[string]any {
  244. cloned := make(map[string]any, len(source))
  245. for key, value := range source {
  246. cloned[key] = value
  247. }
  248. return cloned
  249. }
  250. type grokCredentialHandlerRefresher struct {
  251. mode string
  252. started chan struct{}
  253. once sync.Once
  254. }
  255. func (r *grokCredentialHandlerRefresher) CacheKey(account *service.Account) string {
  256. return service.GrokTokenCacheKey(account)
  257. }
  258. func (r *grokCredentialHandlerRefresher) CanRefresh(account *service.Account) bool {
  259. return account != nil && account.IsGrokOAuth()
  260. }
  261. func (r *grokCredentialHandlerRefresher) NeedsRefresh(account *service.Account, _ time.Duration) bool {
  262. return account != nil && (account.ID == 801 || r.mode == "all_revoked")
  263. }
  264. func (r *grokCredentialHandlerRefresher) Refresh(ctx context.Context, _ *service.Account) (map[string]any, error) {
  265. switch r.mode {
  266. case "revoked", "all_revoked", "mutation_set_error", "mutation_cache":
  267. return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_TOKEN_REFRESH_FAILED", "invalid_grant")
  268. case "provider":
  269. return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_TOKEN_REFRESH_FAILED", "invalid_client")
  270. case "cancel":
  271. r.once.Do(func() { close(r.started) })
  272. <-ctx.Done()
  273. return nil, ctx.Err()
  274. case "transient", "mutation_temp":
  275. return nil, errors.New("temporary refresh transport failure")
  276. default:
  277. return nil, nil
  278. }
  279. }
  280. type grokCredentialHandlerUpstream struct {
  281. service.HTTPUpstream
  282. mu sync.Mutex
  283. hits []int64
  284. requestURLs []string
  285. authorization []string
  286. failAccountID int64
  287. rateLimitIDs map[int64]bool
  288. failureStatus map[int64]int
  289. cancelRequest context.CancelFunc
  290. }
  291. func (u *grokCredentialHandlerUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
  292. var requestBody []byte
  293. if req.Body != nil {
  294. requestBody, _ = io.ReadAll(req.Body)
  295. }
  296. u.mu.Lock()
  297. u.hits = append(u.hits, accountID)
  298. u.requestURLs = append(u.requestURLs, req.URL.String())
  299. u.authorization = append(u.authorization, req.Header.Get("Authorization"))
  300. failAccountID := u.failAccountID
  301. rateLimited := u.rateLimitIDs[accountID]
  302. failureStatus := u.failureStatus[accountID]
  303. cancelRequest := u.cancelRequest
  304. u.mu.Unlock()
  305. if rateLimited {
  306. return &http.Response{
  307. StatusCode: http.StatusTooManyRequests,
  308. Header: http.Header{
  309. "Content-Type": []string{"application/json"},
  310. "Retry-After": []string{"60"},
  311. },
  312. Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"rate limited"}}`)),
  313. }, nil
  314. }
  315. if failureStatus > 0 {
  316. return &http.Response{
  317. StatusCode: failureStatus,
  318. Header: http.Header{"Content-Type": []string{"application/json"}},
  319. Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"upstream unavailable"}}`)),
  320. }, nil
  321. }
  322. if accountID == failAccountID {
  323. if cancelRequest != nil {
  324. cancelRequest()
  325. }
  326. return &http.Response{
  327. StatusCode: http.StatusPaymentRequired,
  328. Header: http.Header{"Content-Type": []string{"application/json"}},
  329. Body: io.NopCloser(bytes.NewBufferString(`{"error":{"message":"payment required"}}`)),
  330. }, nil
  331. }
  332. if bytes.Contains(requestBody, []byte(`"stream":true`)) {
  333. return &http.Response{
  334. StatusCode: http.StatusOK,
  335. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  336. Body: io.NopCloser(bytes.NewBufferString(
  337. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_healthy\",\"model\":\"grok-4.5\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
  338. )),
  339. }, nil
  340. }
  341. if strings.Contains(req.URL.Path, "/chat/completions") {
  342. return &http.Response{
  343. StatusCode: http.StatusOK,
  344. Header: http.Header{"Content-Type": []string{"application/json"}},
  345. Body: io.NopCloser(bytes.NewBufferString(
  346. `{"id":"chatcmpl_healthy","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  347. )),
  348. }, nil
  349. }
  350. return &http.Response{
  351. StatusCode: http.StatusOK,
  352. Header: http.Header{"Content-Type": []string{"application/json"}},
  353. Body: io.NopCloser(bytes.NewBufferString(
  354. `{"id":"resp_healthy","object":"response","model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
  355. )),
  356. }, nil
  357. }
  358. func (u *grokCredentialHandlerUpstream) accountHits() []int64 {
  359. u.mu.Lock()
  360. defer u.mu.Unlock()
  361. return append([]int64(nil), u.hits...)
  362. }
  363. func (u *grokCredentialHandlerUpstream) requests() ([]string, []string) {
  364. u.mu.Lock()
  365. defer u.mu.Unlock()
  366. return append([]string(nil), u.requestURLs...), append([]string(nil), u.authorization...)
  367. }
  368. func TestResponsesCredentialFailoverLoop(t *testing.T) {
  369. gin.SetMode(gin.TestMode)
  370. t.Run("revoked account selects healthy account", func(t *testing.T) {
  371. h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
  372. defer cleanup()
  373. _ = h
  374. recorder := httptest.NewRecorder()
  375. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  376. req.Header.Set("Content-Type", "application/json")
  377. router.ServeHTTP(recorder, req)
  378. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  379. require.Contains(t, recorder.Body.String(), "resp_healthy")
  380. require.Equal(t, []int64{801}, repo.errorIDs())
  381. require.Equal(t, []int64{802}, upstream.accountHits())
  382. requestURLs, authorization := upstream.requests()
  383. require.Equal(t, []string{xai.DefaultCLIBaseURL + "/responses"}, requestURLs)
  384. require.Equal(t, []string{"Bearer healthy-access"}, authorization)
  385. })
  386. t.Run("provider configuration stops before healthy account", func(t *testing.T) {
  387. h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "provider")
  388. defer cleanup()
  389. recorder := httptest.NewRecorder()
  390. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  391. req.Header.Set("Content-Type", "application/json")
  392. router.ServeHTTP(recorder, req)
  393. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  394. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  395. require.Empty(t, repo.errorIDs())
  396. require.Empty(t, upstream.accountHits())
  397. require.Equal(t, 1, repo.selectorCalls())
  398. require.Zero(t, h.gatewayService.SnapshotOpenAIAccountSchedulerMetrics().RuntimeStatsAccountCount,
  399. "provider-scoped auth failure must not penalize the selected account")
  400. })
  401. t.Run("parent cancellation stops before healthy account", func(t *testing.T) {
  402. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "cancel")
  403. defer cleanup()
  404. ctx, cancel := context.WithCancel(context.Background())
  405. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`)).WithContext(ctx)
  406. req.Header.Set("Content-Type", "application/json")
  407. recorder := httptest.NewRecorder()
  408. done := make(chan struct{})
  409. go func() {
  410. defer close(done)
  411. router.ServeHTTP(recorder, req)
  412. }()
  413. select {
  414. case <-time.After(2 * time.Second):
  415. t.Fatal("credential refresh did not start")
  416. case <-findHandlerRefresherStarted(router):
  417. cancel()
  418. }
  419. select {
  420. case <-time.After(2 * time.Second):
  421. t.Fatal("handler did not stop after cancellation")
  422. case <-done:
  423. }
  424. require.Empty(t, repo.errorIDs())
  425. require.Empty(t, upstream.accountHits())
  426. })
  427. t.Run("post-mapping cancellation stops before scheduler mutation or reselection", func(t *testing.T) {
  428. h, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "postmap_cancel")
  429. defer cleanup()
  430. ctx, cancel := context.WithCancel(context.Background())
  431. upstream.mu.Lock()
  432. upstream.failAccountID = 801
  433. upstream.cancelRequest = cancel
  434. upstream.mu.Unlock()
  435. recorder := httptest.NewRecorder()
  436. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`)).WithContext(ctx)
  437. req.Header.Set("Content-Type", "application/json")
  438. router.ServeHTTP(recorder, req)
  439. require.Equal(t, []int64{801}, upstream.accountHits())
  440. require.Empty(t, repo.errorIDs())
  441. require.Equal(t, 1, repo.selectorCalls())
  442. require.Zero(t, h.gatewayService.SnapshotOpenAIAccountSchedulerMetrics().RuntimeStatsAccountCount)
  443. })
  444. t.Run("pre-cancelled request never invokes an account selector", func(t *testing.T) {
  445. tests := []struct {
  446. name string
  447. method string
  448. path string
  449. body string
  450. }{
  451. {name: "responses", method: http.MethodPost, path: "/openai/v1/responses", body: `{"model":"grok","input":"hello","stream":false}`},
  452. {name: "messages", method: http.MethodPost, path: "/openai/v1/messages", body: `{"model":"grok","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`},
  453. {name: "chat completions", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stream":false}`},
  454. {name: "grok media", method: http.MethodGet, path: "/openai/v1/videos/request-1"},
  455. }
  456. for _, tt := range tests {
  457. t.Run(tt.name, func(t *testing.T) {
  458. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
  459. defer cleanup()
  460. ctx, cancel := context.WithCancel(context.Background())
  461. cancel()
  462. recorder := httptest.NewRecorder()
  463. req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)).WithContext(ctx)
  464. req.Header.Set("Content-Type", "application/json")
  465. router.ServeHTTP(recorder, req)
  466. require.Zero(t, repo.selectorCalls())
  467. require.Empty(t, upstream.accountHits())
  468. })
  469. }
  470. })
  471. t.Run("credential state mutation failures stop before reselection", func(t *testing.T) {
  472. for _, mode := range []string{"mutation_set_error", "mutation_temp", "mutation_cache"} {
  473. t.Run(mode, func(t *testing.T) {
  474. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, mode)
  475. defer cleanup()
  476. recorder := httptest.NewRecorder()
  477. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  478. req.Header.Set("Content-Type", "application/json")
  479. router.ServeHTTP(recorder, req)
  480. require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
  481. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  482. require.Empty(t, upstream.accountHits())
  483. require.Equal(t, 1, repo.selectorCalls())
  484. })
  485. }
  486. })
  487. t.Run("missing credential provider stops before upstream or reselection", func(t *testing.T) {
  488. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "nil_provider")
  489. defer cleanup()
  490. recorder := httptest.NewRecorder()
  491. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  492. req.Header.Set("Content-Type", "application/json")
  493. router.ServeHTTP(recorder, req)
  494. require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
  495. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  496. require.Equal(t, 1, repo.selectorCalls())
  497. require.Empty(t, upstream.accountHits())
  498. require.Empty(t, repo.errorIDs())
  499. })
  500. }
  501. func TestResponsesGrok429FailoverIsBounded(t *testing.T) {
  502. gin.SetMode(gin.TestMode)
  503. t.Run("first rate limited account selects healthy account", func(t *testing.T) {
  504. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_429")
  505. defer cleanup()
  506. recorder := httptest.NewRecorder()
  507. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  508. req.Header.Set("Content-Type", "application/json")
  509. router.ServeHTTP(recorder, req)
  510. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  511. require.Contains(t, recorder.Body.String(), "resp_healthy")
  512. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  513. require.Equal(t, []int64{801}, repo.rateLimitedAccountIDs())
  514. })
  515. t.Run("two rate limited accounts stop without sweeping the pool", func(t *testing.T) {
  516. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_429")
  517. defer cleanup()
  518. recorder := httptest.NewRecorder()
  519. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  520. req.Header.Set("Content-Type", "application/json")
  521. router.ServeHTTP(recorder, req)
  522. require.Equal(t, http.StatusTooManyRequests, recorder.Code, recorder.Body.String())
  523. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  524. require.Equal(t, []int64{801, 802}, repo.rateLimitedAccountIDs())
  525. require.NotContains(t, recorder.Body.String(), "expired")
  526. require.NotContains(t, recorder.Body.String(), "healthy-access")
  527. require.NotContains(t, recorder.Body.String(), "rate limited")
  528. })
  529. }
  530. func TestResponsesGrok402FailoverCooldown(t *testing.T) {
  531. gin.SetMode(gin.TestMode)
  532. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_402")
  533. defer cleanup()
  534. recorder := httptest.NewRecorder()
  535. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  536. req.Header.Set("Content-Type", "application/json")
  537. router.ServeHTTP(recorder, req)
  538. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  539. require.Contains(t, recorder.Body.String(), "resp_healthy")
  540. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  541. require.Equal(t, []int64{801}, repo.setTempIDs)
  542. before := repo.selectorCalls()
  543. second := httptest.NewRecorder()
  544. secondReq := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"again","stream":false}`))
  545. secondReq.Header.Set("Content-Type", "application/json")
  546. router.ServeHTTP(second, secondReq)
  547. require.Equal(t, http.StatusOK, second.Code, second.Body.String())
  548. require.Equal(t, before+1, repo.selectorCalls())
  549. require.Equal(t, []int64{801, 802, 802}, upstream.accountHits(), "cooldown must exclude the 402 account from later requests")
  550. }
  551. func TestResponsesGrok429FailoverHandlesMixedStatuses(t *testing.T) {
  552. gin.SetMode(gin.TestMode)
  553. t.Run("429 then 500 stops after the bounded followup", func(t *testing.T) {
  554. _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "mixed_429_500")
  555. defer cleanup()
  556. recorder := httptest.NewRecorder()
  557. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  558. req.Header.Set("Content-Type", "application/json")
  559. router.ServeHTTP(recorder, req)
  560. require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
  561. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  562. require.NotContains(t, recorder.Body.String(), "upstream unavailable")
  563. })
  564. t.Run("500 then 429 permits one healthy followup", func(t *testing.T) {
  565. _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "mixed_500_429")
  566. defer cleanup()
  567. recorder := httptest.NewRecorder()
  568. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  569. req.Header.Set("Content-Type", "application/json")
  570. router.ServeHTTP(recorder, req)
  571. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  572. require.Equal(t, []int64{801, 802, 803}, upstream.accountHits())
  573. })
  574. t.Run("OAuth 429 then API-key failure cannot bypass the bound", func(t *testing.T) {
  575. _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "oauth_429_apikey_500")
  576. defer cleanup()
  577. recorder := httptest.NewRecorder()
  578. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  579. req.Header.Set("Content-Type", "application/json")
  580. router.ServeHTTP(recorder, req)
  581. require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
  582. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  583. })
  584. }
  585. func TestGrokMedia429FailoverIsBounded(t *testing.T) {
  586. gin.SetMode(gin.TestMode)
  587. t.Run("first 429 selects one healthy followup", func(t *testing.T) {
  588. _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "first_429")
  589. defer cleanup()
  590. recorder := httptest.NewRecorder()
  591. req := httptest.NewRequest(http.MethodPost, "/openai/v1/videos/generations", bytes.NewBufferString(`{"model":"grok-imagine-video","prompt":"waves"}`))
  592. req.Header.Set("Content-Type", "application/json")
  593. router.ServeHTTP(recorder, req)
  594. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  595. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  596. })
  597. t.Run("second 429 stops without sweeping a third account", func(t *testing.T) {
  598. _, _, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_429")
  599. defer cleanup()
  600. recorder := httptest.NewRecorder()
  601. req := httptest.NewRequest(http.MethodPost, "/openai/v1/videos/generations", bytes.NewBufferString(`{"model":"grok-imagine-video","prompt":"waves"}`))
  602. req.Header.Set("Content-Type", "application/json")
  603. router.ServeHTTP(recorder, req)
  604. require.Equal(t, http.StatusTooManyRequests, recorder.Code, recorder.Body.String())
  605. require.Equal(t, []int64{801, 802}, upstream.accountHits())
  606. require.NotContains(t, recorder.Body.String(), "rate limited")
  607. })
  608. }
  609. func TestGrokOAuthCredentialFailoverAcrossHTTPHandlers(t *testing.T) {
  610. gin.SetMode(gin.TestMode)
  611. endpoints := []struct {
  612. name string
  613. method string
  614. path string
  615. body string
  616. }{
  617. {name: "messages", method: http.MethodPost, path: "/openai/v1/messages", body: `{"model":"grok","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`},
  618. {name: "chat completions", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stream":false}`},
  619. {name: "chat completions raw fallback", method: http.MethodPost, path: "/openai/v1/chat/completions", body: `{"model":"grok","messages":[{"role":"user","content":"hello"}],"stop":["END"],"stream":false}`},
  620. {name: "grok media", method: http.MethodPost, path: "/openai/v1/videos/generations", body: `{"model":"grok-imagine-video","prompt":"waves"}`},
  621. }
  622. for _, endpoint := range endpoints {
  623. t.Run(endpoint.name+" revoked selects healthy", func(t *testing.T) {
  624. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
  625. defer cleanup()
  626. recorder := httptest.NewRecorder()
  627. req := httptest.NewRequest(endpoint.method, endpoint.path, bytes.NewBufferString(endpoint.body))
  628. req.Header.Set("Content-Type", "application/json")
  629. router.ServeHTTP(recorder, req)
  630. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  631. require.Equal(t, []int64{801}, repo.errorIDs())
  632. require.Equal(t, []int64{802}, upstream.accountHits())
  633. })
  634. t.Run(endpoint.name+" all accounts exhausted safely", func(t *testing.T) {
  635. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "all_revoked")
  636. defer cleanup()
  637. recorder := httptest.NewRecorder()
  638. req := httptest.NewRequest(endpoint.method, endpoint.path, bytes.NewBufferString(endpoint.body))
  639. req.Header.Set("Content-Type", "application/json")
  640. router.ServeHTTP(recorder, req)
  641. require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
  642. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  643. require.NotContains(t, recorder.Body.String(), "revoked-refresh")
  644. require.NotContains(t, recorder.Body.String(), "healthy-refresh")
  645. require.Equal(t, []int64{801, 802}, repo.errorIDs())
  646. require.Empty(t, upstream.accountHits())
  647. })
  648. }
  649. }
  650. func TestGrokOAuthMissingSelectedRowRetriesHealthyAccountWithoutMutation(t *testing.T) {
  651. gin.SetMode(gin.TestMode)
  652. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "missing_row")
  653. defer cleanup()
  654. recorder := httptest.NewRecorder()
  655. req := httptest.NewRequest(http.MethodPost, "/openai/v1/responses", bytes.NewBufferString(`{"model":"grok","input":"hello","stream":false}`))
  656. req.Header.Set("Content-Type", "application/json")
  657. router.ServeHTTP(recorder, req)
  658. require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
  659. require.Equal(t, []int64{802}, upstream.accountHits())
  660. require.Empty(t, repo.errorIDs())
  661. require.Empty(t, repo.setTempIDs)
  662. }
  663. func TestResponsesWebSocketCredentialFailoverLoop(t *testing.T) {
  664. gin.SetMode(gin.TestMode)
  665. dial := func(t *testing.T, router *gin.Engine) (*coderws.Conn, func()) {
  666. t.Helper()
  667. server := httptest.NewServer(router)
  668. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  669. conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/openai/v1/responses", nil)
  670. cancel()
  671. require.NoError(t, err)
  672. return conn, func() {
  673. _ = conn.CloseNow()
  674. server.Close()
  675. }
  676. }
  677. writeFirst := func(t *testing.T, conn *coderws.Conn) {
  678. t.Helper()
  679. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  680. defer cancel()
  681. require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(`{"type":"response.create","model":"grok","input":"hello","stream":false}`)))
  682. }
  683. t.Run("revoked account selects healthy account", func(t *testing.T) {
  684. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "revoked")
  685. defer cleanup()
  686. conn, closeConn := dial(t, router)
  687. defer closeConn()
  688. writeFirst(t, conn)
  689. readCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  690. _, payload, err := conn.Read(readCtx)
  691. cancel()
  692. require.NoError(t, err)
  693. require.Contains(t, string(payload), "resp_healthy")
  694. require.Equal(t, []int64{801}, repo.errorIDs())
  695. require.Equal(t, 2, repo.selectorCalls())
  696. require.Equal(t, []int64{802}, upstream.accountHits())
  697. })
  698. t.Run("provider configuration stops", func(t *testing.T) {
  699. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "provider")
  700. defer cleanup()
  701. conn, closeConn := dial(t, router)
  702. defer closeConn()
  703. writeFirst(t, conn)
  704. readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  705. _, _, err := conn.Read(readCtx)
  706. cancel()
  707. var closeErr coderws.CloseError
  708. require.ErrorAs(t, err, &closeErr)
  709. require.Contains(t, closeErr.Reason, service.GrokCredentialUnavailableClientMessage)
  710. require.Equal(t, 1, repo.selectorCalls())
  711. require.Empty(t, upstream.accountHits())
  712. })
  713. t.Run("parent cancellation prevents reselection", func(t *testing.T) {
  714. _, repo, upstream, router, cleanup := newGrokCredentialFailoverHandler(t, "cancel")
  715. defer cleanup()
  716. conn, closeConn := dial(t, router)
  717. writeFirst(t, conn)
  718. select {
  719. case <-findHandlerRefresherStarted(router):
  720. case <-time.After(2 * time.Second):
  721. t.Fatal("credential refresh did not start")
  722. }
  723. closeConn()
  724. require.Eventually(t, func() bool { return repo.selectorCalls() == 1 }, 2*time.Second, 20*time.Millisecond)
  725. require.Empty(t, repo.errorIDs())
  726. require.Empty(t, upstream.accountHits())
  727. })
  728. }
  729. var handlerRefresherStarted sync.Map
  730. func findHandlerRefresherStarted(router *gin.Engine) <-chan struct{} {
  731. value, _ := handlerRefresherStarted.Load(router)
  732. return value.(chan struct{})
  733. }
  734. func newGrokCredentialFailoverHandler(t *testing.T, mode string) (*OpenAIGatewayHandler, *grokCredentialHandlerRepo, *grokCredentialHandlerUpstream, *gin.Engine, func()) {
  735. t.Helper()
  736. groupID := int64(901)
  737. accounts := []service.Account{
  738. {
  739. ID: 801, Name: "revoked", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
  740. Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 1,
  741. Credentials: map[string]any{
  742. "access_token": "expired", "refresh_token": "revoked-refresh",
  743. "expires_at": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
  744. },
  745. Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
  746. },
  747. {
  748. ID: 802, Name: "healthy", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
  749. Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 2,
  750. Credentials: map[string]any{
  751. "access_token": "healthy-access", "refresh_token": "healthy-refresh",
  752. "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
  753. },
  754. Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
  755. },
  756. }
  757. if mode == "postmap_cancel" || mode == "first_402" || mode == "first_429" || mode == "all_429" || mode == "mixed_429_500" || mode == "mixed_500_429" || mode == "oauth_429_apikey_500" {
  758. accounts[0].Credentials["expires_at"] = time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
  759. }
  760. if mode == "all_429" || mode == "mixed_429_500" || mode == "mixed_500_429" || mode == "oauth_429_apikey_500" {
  761. accounts = append(accounts, service.Account{
  762. ID: 803, Name: "untried-healthy", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth,
  763. Status: service.StatusActive, Schedulable: true, Concurrency: 1, Priority: 3,
  764. Credentials: map[string]any{
  765. "access_token": "untried-healthy-access", "refresh_token": "untried-healthy-refresh",
  766. "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
  767. },
  768. Extra: map[string]any{service.GrokMediaEligibleExtraKey: true},
  769. })
  770. }
  771. if mode == "oauth_429_apikey_500" {
  772. accounts[1].Type = service.AccountTypeAPIKey
  773. accounts[1].Credentials = map[string]any{"api_key": "third-party-key"}
  774. }
  775. if mode == "all_revoked" {
  776. accounts[1].Credentials["expires_at"] = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
  777. }
  778. repo := &grokCredentialHandlerRepo{accounts: accounts, missingOnGet: map[int64]bool{}}
  779. if mode == "missing_row" {
  780. repo.missingOnGet[801] = true
  781. }
  782. if mode == "mutation_set_error" {
  783. repo.setErrorErr = errors.New("database write failed")
  784. }
  785. if mode == "mutation_temp" {
  786. repo.setTempErr = errors.New("database write failed")
  787. }
  788. refresher := &grokCredentialHandlerRefresher{mode: mode, started: make(chan struct{})}
  789. tokenCache := &grokCredentialHandlerTokenCache{}
  790. if mode == "mutation_cache" {
  791. tokenCache.deleteErr = errors.New("cache delete failed")
  792. }
  793. var provider *service.GrokTokenProvider
  794. if mode != "nil_provider" {
  795. provider = service.NewGrokTokenProvider(repo, tokenCache)
  796. provider.SetRefreshAPI(service.NewOAuthRefreshAPI(repo, tokenCache), refresher)
  797. }
  798. upstream := &grokCredentialHandlerUpstream{}
  799. switch mode {
  800. case "first_402":
  801. upstream.failAccountID = 801
  802. case "first_429":
  803. upstream.rateLimitIDs = map[int64]bool{801: true}
  804. case "all_429":
  805. upstream.rateLimitIDs = map[int64]bool{801: true, 802: true}
  806. case "mixed_429_500":
  807. upstream.rateLimitIDs = map[int64]bool{801: true}
  808. upstream.failureStatus = map[int64]int{802: http.StatusInternalServerError}
  809. case "mixed_500_429":
  810. upstream.failureStatus = map[int64]int{801: http.StatusInternalServerError}
  811. upstream.rateLimitIDs = map[int64]bool{802: true}
  812. case "oauth_429_apikey_500":
  813. upstream.rateLimitIDs = map[int64]bool{801: true}
  814. upstream.failureStatus = map[int64]int{802: http.StatusInternalServerError}
  815. }
  816. cfg := &config.Config{RunMode: config.RunModeSimple}
  817. cfg.Gateway.MaxAccountSwitches = 3
  818. billingCache := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
  819. gateway := service.NewOpenAIGatewayService(
  820. repo, nil, nil, nil, nil, nil, nil, cfg, nil, nil,
  821. service.NewBillingService(cfg, nil), nil, billingCache, upstream,
  822. &service.DeferredService{}, nil, provider, nil, nil, nil, nil, nil,
  823. )
  824. cache := &concurrencyCacheMock{
  825. acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
  826. acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
  827. }
  828. h := NewOpenAIGatewayHandler(gateway, service.NewConcurrencyService(cache), billingCache, &service.APIKeyService{}, nil, nil, nil, nil, cfg)
  829. apiKey := &service.APIKey{
  830. ID: 902, GroupID: &groupID,
  831. User: &service.User{ID: 903, Status: service.StatusActive},
  832. Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, Status: service.StatusActive, AllowImageGeneration: true},
  833. }
  834. router := gin.New()
  835. router.Use(func(c *gin.Context) {
  836. c.Set(string(middleware.ContextKeyAPIKey), apiKey)
  837. c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
  838. c.Next()
  839. })
  840. router.POST("/openai/v1/responses", h.Responses)
  841. router.GET("/openai/v1/responses", h.ResponsesWebSocket)
  842. router.POST("/openai/v1/messages", h.Messages)
  843. router.POST("/openai/v1/chat/completions", h.ChatCompletions)
  844. router.POST("/openai/v1/videos/generations", h.GrokVideoGeneration)
  845. router.GET("/openai/v1/videos/:request_id", h.GrokVideoStatus)
  846. handlerRefresherStarted.Store(router, refresher.started)
  847. cleanup := func() {
  848. handlerRefresherStarted.Delete(router)
  849. billingCache.Stop()
  850. }
  851. return h, repo, upstream, router, cleanup
  852. }