openai_gateway_credential_failover_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. //go:build unit
  2. package handler
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. func TestGatewayChatCredentialStopDoesNotSelectAnotherAccountAndReturnsSafe503(t *testing.T) {
  16. gin.SetMode(gin.TestMode)
  17. stopErr := &service.UpstreamFailoverError{
  18. Stage: service.GatewayFailureStageAccountAuth,
  19. Scope: service.GatewayFailureScopeProvider,
  20. Reason: service.GrokCredentialReasonProviderConfig,
  21. NextAccountAction: service.NextAccountStop,
  22. ClientStatusCode: http.StatusTeapot,
  23. ClientMessage: "invalid_client client_secret=must-not-leak",
  24. }
  25. state := NewFailoverState(3, false)
  26. action := state.HandleFailoverError(context.Background(), &mockTempUnscheduler{}, 71, service.PlatformGrok, 0, stopErr)
  27. require.Equal(t, FailoverExhausted, action)
  28. require.Zero(t, state.SwitchCount)
  29. require.Empty(t, state.FailedAccountIDs)
  30. recorder := httptest.NewRecorder()
  31. c, _ := gin.CreateTestContext(recorder)
  32. (&GatewayHandler{}).handleCCFailoverExhausted(c, state.LastFailoverErr, false)
  33. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  34. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  35. require.NotContains(t, recorder.Body.String(), "invalid_client")
  36. require.NotContains(t, recorder.Body.String(), "client_secret")
  37. }
  38. func TestGatewayChatAntigravityCredentialFailureReturnsActionableMessage(t *testing.T) {
  39. gin.SetMode(gin.TestMode)
  40. recorder := httptest.NewRecorder()
  41. c, _ := gin.CreateTestContext(recorder)
  42. (&GatewayHandler{}).handleCCFailoverExhausted(c, &service.UpstreamFailoverError{
  43. StatusCode: http.StatusUnauthorized,
  44. Stage: service.GatewayFailureStageAccountAuth,
  45. Scope: service.GatewayFailureScopeAccount,
  46. Reason: service.AntigravityCredentialRejectedReason,
  47. NextAccountAction: service.NextAccountRetry,
  48. ClientStatusCode: http.StatusBadGateway,
  49. ClientMessage: service.AntigravityCredentialRejectedClientMessage,
  50. ResponseBody: []byte(`{"error":{"message":"Invalid bearer token","refresh_token":"must-not-leak"}}`),
  51. }, false)
  52. require.Equal(t, http.StatusBadGateway, recorder.Code)
  53. require.Contains(t, recorder.Body.String(), service.AntigravityCredentialRejectedClientMessage)
  54. require.NotContains(t, strings.ToLower(recorder.Body.String()), "bearer")
  55. require.NotContains(t, strings.ToLower(recorder.Body.String()), "refresh_token")
  56. }
  57. func TestOpenAIAccessStateCredentialFailureUsesTypedSafeResponse(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. recorder := httptest.NewRecorder()
  60. c, _ := gin.CreateTestContext(recorder)
  61. (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
  62. StatusCode: http.StatusForbidden,
  63. Stage: service.GatewayFailureStageAccountAuth,
  64. Scope: service.GatewayFailureScopeAccount,
  65. Reason: service.OpenAIUpstreamAccessStateReason,
  66. NextAccountAction: service.NextAccountRetry,
  67. ClientStatusCode: http.StatusBadGateway,
  68. ClientMessage: "Upstream access is temporarily unavailable, please retry later",
  69. ResponseBody: []byte(`{"error":{"message":"Your workspace is deactivated","token":"must-not-leak"}}`),
  70. }, false)
  71. require.Equal(t, http.StatusBadGateway, recorder.Code)
  72. require.Contains(t, recorder.Body.String(), "Upstream access is temporarily unavailable")
  73. require.NotContains(t, strings.ToLower(recorder.Body.String()), "deactivated")
  74. require.NotContains(t, recorder.Body.String(), "must-not-leak")
  75. }
  76. func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testing.T) {
  77. gin.SetMode(gin.TestMode)
  78. message := "Our servers are currently overloaded. Please try again later."
  79. failoverErr := &service.UpstreamFailoverError{
  80. StatusCode: http.StatusBadRequest,
  81. ResponseBody: []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`),
  82. RetryableOnSameAccount: true,
  83. RequestScopedTransient: true,
  84. ClientStatusCode: http.StatusServiceUnavailable,
  85. ClientMessage: message,
  86. }
  87. t.Run("native_openai", func(t *testing.T) {
  88. recorder := httptest.NewRecorder()
  89. c, _ := gin.CreateTestContext(recorder)
  90. (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, failoverErr, false)
  91. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  92. require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.type").String())
  93. require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
  94. require.NotContains(t, recorder.Body.String(), "server_is_overloaded")
  95. })
  96. t.Run("responses_compat", func(t *testing.T) {
  97. recorder := httptest.NewRecorder()
  98. c, _ := gin.CreateTestContext(recorder)
  99. (&GatewayHandler{}).handleResponsesFailoverExhausted(c, failoverErr, false)
  100. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  101. require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.code").String())
  102. require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
  103. })
  104. t.Run("anthropic_compat", func(t *testing.T) {
  105. recorder := httptest.NewRecorder()
  106. c, _ := gin.CreateTestContext(recorder)
  107. (&OpenAIGatewayHandler{}).handleAnthropicFailoverExhausted(c, failoverErr, false)
  108. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  109. require.Equal(t, "api_error", gjson.Get(recorder.Body.String(), "error.type").String())
  110. require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
  111. })
  112. }
  113. func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
  114. gin.SetMode(gin.TestMode)
  115. recorder := httptest.NewRecorder()
  116. c, _ := gin.CreateTestContext(recorder)
  117. body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
  118. (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
  119. StatusCode: http.StatusBadRequest,
  120. ResponseBody: body,
  121. }, false)
  122. require.Equal(t, http.StatusBadRequest, recorder.Code)
  123. require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
  124. require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
  125. require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
  126. require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
  127. }
  128. func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
  129. gin.SetMode(gin.TestMode)
  130. recorder := httptest.NewRecorder()
  131. c, _ := gin.CreateTestContext(recorder)
  132. body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
  133. (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
  134. StatusCode: http.StatusBadRequest,
  135. ResponseBody: body,
  136. }, false)
  137. require.Equal(t, http.StatusBadRequest, recorder.Code)
  138. require.NotContains(t, recorder.Body.String(), "super-secret-value")
  139. require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
  140. recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
  141. require.True(t, ok)
  142. require.NotContains(t, recorded, "super-secret-value")
  143. require.Contains(t, recorded, "access_token=***")
  144. }
  145. func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
  146. gin.SetMode(gin.TestMode)
  147. recorder := httptest.NewRecorder()
  148. c, _ := gin.CreateTestContext(recorder)
  149. official := "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"server_error\",\"message\":\"official failure\"}}}\n\n"
  150. _, err := c.Writer.Write([]byte(official))
  151. require.NoError(t, err)
  152. service.MarkOpsStreamError(c, "server_error", "official failure", http.StatusBadGateway)
  153. (&GatewayHandler{}).handleResponsesFailoverExhausted(c, &service.UpstreamFailoverError{
  154. StatusCode: http.StatusBadGateway,
  155. ResponseBody: []byte(`{"error":{"message":"fallback failure"}}`),
  156. }, true)
  157. require.Equal(t, official, recorder.Body.String())
  158. streamErr, ok := service.GetOpsStreamError(c)
  159. require.True(t, ok)
  160. require.Equal(t, "official failure", streamErr.Message)
  161. markerRecorder := httptest.NewRecorder()
  162. markerContext, _ := gin.CreateTestContext(markerRecorder)
  163. (&GatewayHandler{}).handleResponsesFailoverExhausted(markerContext, &service.UpstreamFailoverError{
  164. StatusCode: http.StatusTooManyRequests,
  165. }, true)
  166. require.Contains(t, markerRecorder.Body.String(), "event: response.failed")
  167. require.Equal(t, 1, strings.Count(markerRecorder.Body.String(), "event: response.failed"))
  168. streamErr, ok = service.GetOpsStreamError(markerContext)
  169. require.True(t, ok)
  170. require.Equal(t, http.StatusTooManyRequests, streamErr.IntendedStatus)
  171. require.Equal(t, "rate_limit_error", streamErr.ErrType)
  172. heartbeatRecorder := httptest.NewRecorder()
  173. heartbeatContext, _ := gin.CreateTestContext(heartbeatRecorder)
  174. heartbeat := ": keepalive\n\n"
  175. written, err := heartbeatRecorder.Write([]byte(heartbeat))
  176. require.NoError(t, err)
  177. recordGatewayStreamHeartbeat(heartbeatContext, written)
  178. (&GatewayHandler{}).handleResponsesFailoverExhausted(heartbeatContext, &service.UpstreamFailoverError{
  179. StatusCode: http.StatusBadGateway,
  180. }, true)
  181. require.True(t, strings.HasPrefix(heartbeatRecorder.Body.String(), heartbeat))
  182. require.Equal(t, 1, strings.Count(heartbeatRecorder.Body.String(), "event: response.failed"))
  183. }
  184. func TestGatewayChatInferenceExhaustionRestoresRetryAfter(t *testing.T) {
  185. gin.SetMode(gin.TestMode)
  186. recorder := httptest.NewRecorder()
  187. c, _ := gin.CreateTestContext(recorder)
  188. (&GatewayHandler{}).handleCCFailoverExhausted(c, &service.UpstreamFailoverError{
  189. StatusCode: http.StatusTooManyRequests,
  190. ResponseHeaders: http.Header{"Retry-After": []string{"45"}},
  191. }, false)
  192. require.Equal(t, http.StatusTooManyRequests, recorder.Code)
  193. require.Equal(t, "45", recorder.Header().Get("Retry-After"))
  194. }
  195. func TestCredentialFailoverExhaustionReturnsFixedSafe503(t *testing.T) {
  196. gin.SetMode(gin.TestMode)
  197. recorder := httptest.NewRecorder()
  198. c, _ := gin.CreateTestContext(recorder)
  199. h := &OpenAIGatewayHandler{}
  200. h.handleFailoverExhausted(c, &service.UpstreamFailoverError{
  201. Stage: service.GatewayFailureStageAccountAuth,
  202. Scope: service.GatewayFailureScopeAccount,
  203. Reason: service.GrokCredentialReasonRevoked,
  204. NextAccountAction: service.NextAccountRetry,
  205. ClientStatusCode: http.StatusTeapot,
  206. ClientMessage: "invalid_grant refresh_token=must-not-leak",
  207. }, false)
  208. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  209. require.Contains(t, recorder.Body.String(), service.GrokCredentialUnavailableClientMessage)
  210. require.NotContains(t, strings.ToLower(recorder.Body.String()), "invalid_grant")
  211. require.NotContains(t, strings.ToLower(recorder.Body.String()), "refresh_token")
  212. require.NotContains(t, recorder.Body.String(), "must-not-leak")
  213. }
  214. func TestInferenceFailoverExhaustionRestoresRetryAfter(t *testing.T) {
  215. gin.SetMode(gin.TestMode)
  216. recorder := httptest.NewRecorder()
  217. c, _ := gin.CreateTestContext(recorder)
  218. h := &OpenAIGatewayHandler{}
  219. h.handleFailoverExhausted(c, &service.UpstreamFailoverError{
  220. StatusCode: http.StatusTooManyRequests,
  221. ResponseHeaders: http.Header{"Retry-After": []string{"17"}},
  222. }, false)
  223. require.Equal(t, http.StatusTooManyRequests, recorder.Code)
  224. require.Equal(t, "17", recorder.Header().Get("Retry-After"))
  225. }
  226. func TestFailoverExhaustionRejectsSecretBearingRetryAfter(t *testing.T) {
  227. gin.SetMode(gin.TestMode)
  228. recorder := httptest.NewRecorder()
  229. c, _ := gin.CreateTestContext(recorder)
  230. h := &OpenAIGatewayHandler{}
  231. h.handleFailoverExhausted(c, &service.UpstreamFailoverError{
  232. StatusCode: http.StatusTooManyRequests,
  233. ResponseHeaders: http.Header{"Retry-After": []string{"refresh_token=must-not-leak"}},
  234. }, false)
  235. require.Equal(t, http.StatusTooManyRequests, recorder.Code)
  236. require.Empty(t, recorder.Header().Get("Retry-After"))
  237. require.NotContains(t, recorder.Body.String(), "must-not-leak")
  238. }
  239. func TestFailoverExhaustionRejectsFarFutureRetryAfterDate(t *testing.T) {
  240. gin.SetMode(gin.TestMode)
  241. recorder := httptest.NewRecorder()
  242. c, _ := gin.CreateTestContext(recorder)
  243. h := &OpenAIGatewayHandler{}
  244. h.handleFailoverExhausted(c, &service.UpstreamFailoverError{
  245. StatusCode: http.StatusTooManyRequests,
  246. ResponseHeaders: http.Header{
  247. "Retry-After": []string{time.Now().Add(30 * 24 * time.Hour).UTC().Format(http.TimeFormat)},
  248. },
  249. }, false)
  250. require.Equal(t, http.StatusTooManyRequests, recorder.Code)
  251. require.Empty(t, recorder.Header().Get("Retry-After"))
  252. }
  253. func TestFailoverExhaustionAllowsBoundedRetryAfterDate(t *testing.T) {
  254. gin.SetMode(gin.TestMode)
  255. recorder := httptest.NewRecorder()
  256. c, _ := gin.CreateTestContext(recorder)
  257. h := &OpenAIGatewayHandler{}
  258. retryAfter := time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)
  259. h.handleFailoverExhausted(c, &service.UpstreamFailoverError{
  260. StatusCode: http.StatusTooManyRequests,
  261. ResponseHeaders: http.Header{"Retry-After": []string{retryAfter}},
  262. }, false)
  263. require.Equal(t, http.StatusTooManyRequests, recorder.Code)
  264. require.Equal(t, retryAfter, recorder.Header().Get("Retry-After"))
  265. }
  266. func TestOpsClassificationTreatsCredentialFailureAsAuthNotInference(t *testing.T) {
  267. gin.SetMode(gin.TestMode)
  268. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  269. c.Set(service.OpsUpstreamStatusCodeKey, http.StatusForbidden)
  270. c.Set(service.OpsUpstreamErrorMessageKey, "stale inference message")
  271. c.Set(service.OpsUpstreamErrorDetailKey, "stale inference detail")
  272. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  273. {Stage: string(service.GatewayFailureStageInference), UpstreamStatusCode: http.StatusForbidden, Message: "stale inference message", Detail: "stale inference detail"},
  274. {
  275. Stage: string(service.GatewayFailureStageAccountAuth),
  276. Scope: string(service.GatewayFailureScopeAccount),
  277. Reason: string(service.GrokCredentialReasonRevoked),
  278. UpstreamStatusCode: 0,
  279. Message: "Grok OAuth credentials require account action",
  280. },
  281. })
  282. phase, _, owner, source := classifyOpsErrorLog(c, "upstream_error", service.GrokCredentialUnavailableClientMessage, "", http.StatusServiceUnavailable)
  283. require.Equal(t, "account_auth", phase)
  284. require.Equal(t, "provider", owner)
  285. require.Equal(t, "gateway", source)
  286. entry := &service.OpsInsertErrorLogInput{}
  287. applyOpsUpstreamFieldsFromContext(c, entry)
  288. require.NotNil(t, entry.UpstreamStatusCode)
  289. require.Zero(t, *entry.UpstreamStatusCode)
  290. require.NotNil(t, entry.UpstreamErrorMessage)
  291. require.Equal(t, "Grok OAuth credentials require account action", *entry.UpstreamErrorMessage)
  292. require.Nil(t, entry.UpstreamErrorDetail)
  293. require.Len(t, entry.UpstreamErrors, 2)
  294. require.Equal(t, http.StatusForbidden, entry.UpstreamErrors[0].UpstreamStatusCode)
  295. }
  296. func TestOpsRecoveredCredentialFailoverDoesNotCreateRequestError(t *testing.T) {
  297. setupOpsErrorLogTestQueue(t, 2)
  298. gin.SetMode(gin.TestMode)
  299. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  300. router := gin.New()
  301. router.Use(OpsErrorLoggerMiddleware(ops))
  302. router.GET("/openai/v1/responses", func(c *gin.Context) {
  303. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  304. {Stage: string(service.GatewayFailureStageInference), UpstreamStatusCode: http.StatusForbidden, Message: "earlier inference failure"},
  305. {
  306. Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
  307. Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
  308. },
  309. })
  310. c.JSON(http.StatusOK, gin.H{"ok": true})
  311. })
  312. recorder := httptest.NewRecorder()
  313. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil))
  314. require.Equal(t, http.StatusOK, recorder.Code)
  315. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  316. job := <-opsErrorLogQueue
  317. require.Equal(t, http.StatusOK, job.entry.StatusCode)
  318. require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase)
  319. require.NotNil(t, job.entry.UpstreamErrorsJSON)
  320. events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
  321. require.NoError(t, err)
  322. require.Len(t, events, 2)
  323. require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[1].Stage)
  324. }
  325. func TestOpsWebSocketCredentialFailoverSuccessDoesNotCreateRequestError(t *testing.T) {
  326. setupOpsErrorLogTestQueue(t, 2)
  327. gin.SetMode(gin.TestMode)
  328. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  329. router := gin.New()
  330. router.Use(OpsErrorLoggerMiddleware(ops))
  331. router.GET("/openai/v1/responses", func(c *gin.Context) {
  332. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  333. Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
  334. Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
  335. }})
  336. })
  337. recorder := httptest.NewRecorder()
  338. request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
  339. request.Header.Set("Connection", "Upgrade")
  340. request.Header.Set("Upgrade", "websocket")
  341. router.ServeHTTP(recorder, request)
  342. require.Equal(t, http.StatusOK, recorder.Code)
  343. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  344. job := <-opsErrorLogQueue
  345. require.Equal(t, http.StatusOK, job.entry.StatusCode)
  346. require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase)
  347. require.NotNil(t, job.entry.UpstreamErrorsJSON)
  348. events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
  349. require.NoError(t, err)
  350. require.Len(t, events, 1)
  351. require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[0].Stage)
  352. }
  353. func TestOpsWebSocketCredentialFailoverExhaustedIsRecorded(t *testing.T) {
  354. setupOpsErrorLogTestQueue(t, 2)
  355. gin.SetMode(gin.TestMode)
  356. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  357. router := gin.New()
  358. router.Use(OpsErrorLoggerMiddleware(ops))
  359. router.GET("/openai/v1/responses", func(c *gin.Context) {
  360. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  361. Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
  362. Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
  363. }})
  364. closeOpenAIWSFailoverExhausted(c, nil, &service.UpstreamFailoverError{
  365. Stage: service.GatewayFailureStageAccountAuth,
  366. Scope: service.GatewayFailureScopeAccount,
  367. Reason: service.GrokCredentialReasonRevoked,
  368. NextAccountAction: service.NextAccountStop,
  369. })
  370. })
  371. recorder := httptest.NewRecorder()
  372. request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
  373. request.Header.Set("Connection", "Upgrade")
  374. request.Header.Set("Upgrade", "websocket")
  375. router.ServeHTTP(recorder, request)
  376. require.Equal(t, http.StatusOK, recorder.Code)
  377. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  378. job := <-opsErrorLogQueue
  379. require.Equal(t, "account_auth", job.entry.ErrorPhase)
  380. require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
  381. require.Equal(t, service.GrokCredentialUnavailableClientMessage, job.entry.ErrorMessage)
  382. }
  383. func TestAccountTrafficWebSocketDenialRecordsLocalReason(t *testing.T) {
  384. setupOpsErrorLogTestQueue(t, 2)
  385. gin.SetMode(gin.TestMode)
  386. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  387. router := gin.New()
  388. router.Use(OpsErrorLoggerMiddleware(ops))
  389. router.GET("/openai/v1/responses", func(c *gin.Context) {
  390. closeOpenAIWSFailoverExhausted(c, nil, (&service.AccountTrafficLimitError{Reason: "当前账号达到本地 RPM 上限"}).FailoverError())
  391. })
  392. recorder := httptest.NewRecorder()
  393. request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
  394. request.Header.Set("Connection", "Upgrade")
  395. request.Header.Set("Upgrade", "websocket")
  396. router.ServeHTTP(recorder, request)
  397. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  398. job := <-opsErrorLogQueue
  399. require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
  400. require.Equal(t, "当前账号达到本地 RPM 上限", job.entry.ErrorMessage)
  401. }