grok_upstream_failure_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func TestClassifyGrokUpstreamFailure_FreeUsage(t *testing.T) {
  11. cases := []struct {
  12. name string
  13. status int
  14. body string
  15. }{
  16. {
  17. name: "code free-usage-exhausted",
  18. status: http.StatusTooManyRequests,
  19. body: `{"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage for model grok-4.5. Usage resets over a rolling 24-hour window."}}`,
  20. },
  21. {
  22. name: "chinese body without 429",
  23. status: http.StatusBadRequest,
  24. body: `{"error":{"message":"模型额度用完,请稍后再试"}}`,
  25. },
  26. {
  27. name: "token pair with free marker",
  28. status: http.StatusOK,
  29. body: `{"error":{"message":"free usage tokens (actual / limit): 2000000 / 2000000 for model grok-4.5"}}`,
  30. },
  31. }
  32. for _, tc := range cases {
  33. t.Run(tc.name, func(t *testing.T) {
  34. d := classifyGrokUpstreamFailure(tc.status, []byte(tc.body), "grok-4.5")
  35. require.Equal(t, GrokFailureFreeUsage, d.Class)
  36. require.True(t, d.ShouldCooldown)
  37. require.True(t, d.ShouldFailover)
  38. require.False(t, d.BlockModel, "free-usage must not soft-block models")
  39. require.Equal(t, grokFreeUsageProbeCooldown, d.Cooldown)
  40. })
  41. }
  42. }
  43. func TestClassifyGrokUpstreamFailure_EmptyUpstream(t *testing.T) {
  44. d := classifyGrokUpstreamFailure(http.StatusBadGateway, []byte(`empty model output: no content/tool_calls`), "grok-4.5")
  45. require.Equal(t, GrokFailureEmptyUpstream, d.Class)
  46. require.True(t, d.ShouldCooldown)
  47. require.True(t, d.ShouldFailover)
  48. require.True(t, d.BlockModel)
  49. require.Equal(t, 4*time.Minute, d.Cooldown)
  50. }
  51. func TestClassifyGrokUpstreamFailure_ModelCapacityUsesShortCooldown(t *testing.T) {
  52. d := classifyGrokUpstreamFailure(http.StatusTooManyRequests,
  53. []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`), "grok-4.6")
  54. require.Equal(t, GrokFailureModelCapacity, d.Class)
  55. require.Equal(t, time.Minute, d.Cooldown)
  56. require.False(t, d.BlockModel)
  57. }
  58. func TestClassifyGrokUpstreamFailure_Billing(t *testing.T) {
  59. d := classifyGrokUpstreamFailure(http.StatusForbidden, []byte(`{"code":"personal-team-blocked:spending-limit","error":"spending limit reached"}`), "")
  60. require.Equal(t, GrokFailureBilling, d.Class)
  61. require.True(t, d.ShouldCooldown)
  62. require.True(t, d.ShouldFailover)
  63. }
  64. func TestClassifyGrokUpstreamFailure_GrokSubscriptionRequiredIsBilling(t *testing.T) {
  65. d := classifyGrokUpstreamFailure(http.StatusPaymentRequired,
  66. []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`), "grok-4.6")
  67. require.Equal(t, GrokFailureBilling, d.Class)
  68. require.True(t, d.ShouldFailover)
  69. require.True(t, d.ShouldCooldown)
  70. }
  71. func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) {
  72. account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth}
  73. require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests,
  74. []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`)))
  75. require.False(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests,
  76. []byte(`{"error":{"message":"rate limit exceeded"}}`)))
  77. require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired,
  78. []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`)))
  79. poolAccount := &Account{ID: 9108, Platform: PlatformGrok, Type: AccountTypeOAuth,
  80. Credentials: map[string]any{"pool_mode": true}}
  81. require.False(t, grokRetryableOnSameAccount(poolAccount, http.StatusTooManyRequests,
  82. []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)),
  83. "pool free-usage must fail over instead of retrying the exhausted account")
  84. require.False(t, grokRetryableOnSameAccount(account, http.StatusBadRequest,
  85. []byte(`{"error":{"message":"capacity field is invalid"}}`)))
  86. nonGrok := &Account{ID: 9106, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  87. require.False(t, grokRetryableOnSameAccount(nonGrok, http.StatusTooManyRequests,
  88. []byte(`{"error":{"message":"model at capacity"}}`)))
  89. }
  90. func TestShouldMarkGrokTeamModelRateLimit_ExcludesCapacity(t *testing.T) {
  91. require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests,
  92. []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`)))
  93. require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests,
  94. []byte(`{"error":{"message":"rate limit exceeded"}}`)))
  95. require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest,
  96. []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)))
  97. require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest,
  98. []byte(`{"error":{"message":"invalid request"}}`)))
  99. }
  100. func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) {
  101. account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth}
  102. retryable, delay, deadline, retryMax := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests,
  103. []byte(`{"error":{"message":"model capacity exceeded"}}`))
  104. require.True(t, retryable)
  105. require.Equal(t, 500*time.Millisecond, delay)
  106. require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second)
  107. require.Equal(t, 1, retryMax)
  108. retryable, delay, deadline, retryMax = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests,
  109. []byte(`{"error":{"message":"rate limit exceeded"}}`))
  110. require.False(t, retryable)
  111. require.Zero(t, delay)
  112. require.True(t, deadline.IsZero())
  113. require.Zero(t, retryMax)
  114. }
  115. func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) {
  116. d := classifyGrokUpstreamFailure(http.StatusBadRequest, []byte(`{"error":{"message":"invalid tool schema"}}`), "")
  117. require.Equal(t, GrokFailureNone, d.Class)
  118. require.False(t, d.ShouldCooldown)
  119. require.False(t, d.ShouldFailover)
  120. }
  121. func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) {
  122. // Proxy may rewrite free-usage into synthetic 502; body must win.
  123. d := classifyGrokUpstreamFailure(http.StatusBadGateway, []byte(`subscription:free-usage-exhausted for model grok-4.3`), "grok-4.3")
  124. require.Equal(t, GrokFailureFreeUsage, d.Class)
  125. require.NotEqual(t, GrokFailureServer, d.Class)
  126. }
  127. func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) {
  128. cases := []string{
  129. `{"error":{"message":"Could not decode the compaction blob. Ensure it is unmodified from the compact response"}}`,
  130. `{"code":"compaction_decode_error","message":"invalid response history"}`,
  131. }
  132. for _, body := range cases {
  133. d := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, []byte(body), "grok-4.6")
  134. require.Equal(t, GrokFailureCompatibility, d.Class, body)
  135. require.True(t, d.ShouldFailover, body)
  136. require.False(t, d.ShouldCooldown, body)
  137. require.Zero(t, d.Cooldown, body)
  138. }
  139. }
  140. func TestClassifyGrokUpstreamFailure_CompatibilityRequiresClientError(t *testing.T) {
  141. body := []byte(`{"error":{"message":"upstream failed while handling the compaction blob"}}`)
  142. for _, status := range []int{http.StatusBadGateway, http.StatusInternalServerError} {
  143. d := classifyGrokUpstreamFailure(status, body, "grok-4.6")
  144. require.NotEqual(t, GrokFailureCompatibility, d.Class)
  145. require.True(t, d.ShouldCooldown)
  146. }
  147. }
  148. func TestClassifyGrokUpstreamFailure_GenericShapeErrorDoesNotFailover(t *testing.T) {
  149. d := classifyGrokUpstreamFailure(http.StatusBadRequest,
  150. []byte(`{"error":{"message":"data did not match any variant of the untagged enum content"}}`), "grok-4.6")
  151. require.NotEqual(t, GrokFailureCompatibility, d.Class)
  152. require.False(t, d.ShouldFailover)
  153. }
  154. func TestShouldFailoverGrokUpstreamError_FreeUsageBody(t *testing.T) {
  155. svc := &OpenAIGatewayService{}
  156. body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`)
  157. require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusBadRequest, body))
  158. }
  159. func TestShouldFailoverGrokUpstreamError_CompatibilityBody(t *testing.T) {
  160. svc := &OpenAIGatewayService{}
  161. body := []byte(`{"error":{"message":"Could not decode the compaction blob"}}`)
  162. require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body))
  163. }
  164. func TestShouldFailoverGrokUpstreamError_ContentPolicyStillNoFailover(t *testing.T) {
  165. svc := &OpenAIGatewayService{}
  166. body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)
  167. require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body))
  168. }
  169. func TestHandleGrokAccountUpstreamError_FreeUsageBodyCoolsAccount(t *testing.T) {
  170. repo := &grokQuotaAccountRepo{}
  171. svc := &OpenAIGatewayService{accountRepo: repo}
  172. account := &Account{ID: 9101, Platform: PlatformGrok, Type: AccountTypeOAuth}
  173. before := time.Now()
  174. body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage. Usage resets over a rolling 24-hour window."}}`)
  175. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body)
  176. require.Equal(t, 1, repo.tempUnschedCalls)
  177. require.Equal(t, "grok free usage exhausted", repo.lastTempUnschedReason)
  178. // Rolling-window exhaustion must use a short probe cooldown when no
  179. // upstream absolute reset is available; it must not start a 24h lock here.
  180. require.Greater(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown-time.Second))
  181. require.Less(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown+time.Second))
  182. }
  183. func TestHandleGrokAccountUpstreamError_FreeUsageUsesUpstreamReset(t *testing.T) {
  184. repo := &grokQuotaAccountRepo{}
  185. svc := &OpenAIGatewayService{accountRepo: repo}
  186. account := &Account{ID: 9102, Platform: PlatformGrok, Type: AccountTypeOAuth}
  187. body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted; rolling 24-hour window"}}`)
  188. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests,
  189. http.Header{"Retry-After": []string{"3600"}}, body)
  190. require.Zero(t, repo.tempUnschedCalls)
  191. require.WithinDuration(t, time.Now().Add(time.Hour), repo.lastRateLimitResetAt, 2*time.Second)
  192. }
  193. func TestHandleGrokAccountUpstreamError_EmptyOutputCoolsAccount(t *testing.T) {
  194. repo := &grokQuotaAccountRepo{}
  195. svc := &OpenAIGatewayService{accountRepo: repo}
  196. account := &Account{ID: 9102, Platform: PlatformGrok, Type: AccountTypeOAuth}
  197. before := time.Now()
  198. svc.handleGrokAccountUpstreamError(
  199. context.Background(), account, http.StatusBadGateway, nil,
  200. []byte(`empty model output: no content/tool_calls`),
  201. )
  202. require.Equal(t, 1, repo.tempUnschedCalls)
  203. require.Equal(t, "grok empty model output", repo.lastTempUnschedReason)
  204. require.WithinDuration(t, before.Add(4*time.Minute), repo.lastTempUnschedUntil, time.Second)
  205. }
  206. func TestHandleGrokAccountUpstreamError_MultiAgentCapacityBlocksOnlyThatModel(t *testing.T) {
  207. repo := &grokQuotaAccountRepo{}
  208. svc := &OpenAIGatewayService{accountRepo: repo}
  209. account := &Account{ID: 9120, Platform: PlatformGrok, Type: AccountTypeOAuth}
  210. ctx := withGrokTeamRateLimitModel(context.Background(), "grok-4.20-multi-agent-0309")
  211. svc.handleGrokAccountUpstreamError(
  212. ctx, account, http.StatusBadGateway, nil,
  213. []byte(`{"error":{"message":"engine_overloaded"}}`),
  214. )
  215. require.Zero(t, repo.tempUnschedCalls)
  216. require.True(t, isGrokModelQuotaBlocked(account.ID, "grok-4.20-multi-agent-0309", time.Now()))
  217. require.False(t, isGrokModelQuotaBlocked(account.ID, "grok-4.5", time.Now()))
  218. }
  219. func TestHandleGrokAccountUpstreamError_CapacityNeverCoolsAccount(t *testing.T) {
  220. repo := &grokQuotaAccountRepo{}
  221. svc := &OpenAIGatewayService{accountRepo: repo}
  222. account := &Account{ID: 9121, Platform: PlatformGrok, Type: AccountTypeOAuth}
  223. ctx := withGrokTeamRateLimitModel(context.Background(), "grok-4.6")
  224. svc.handleGrokAccountUpstreamError(ctx, account, http.StatusTooManyRequests, nil,
  225. []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))
  226. require.Zero(t, repo.tempUnschedCalls)
  227. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  228. }
  229. func TestHandleGrokAccountUpstreamError_FreeUsageDoesNotCoolPoolMode(t *testing.T) {
  230. repo := &grokQuotaAccountRepo{}
  231. svc := &OpenAIGatewayService{accountRepo: repo}
  232. account := &Account{
  233. ID: 9103,
  234. Platform: PlatformGrok,
  235. Type: AccountTypeAPIKey,
  236. Credentials: map[string]any{
  237. "pool_mode": true,
  238. },
  239. }
  240. body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`)
  241. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body)
  242. require.Zero(t, repo.tempUnschedCalls)
  243. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  244. }
  245. func TestHandleGrokAccountUpstreamError_ContentPolicyStillNoMutation(t *testing.T) {
  246. repo := &grokQuotaAccountRepo{}
  247. svc := &OpenAIGatewayService{accountRepo: repo}
  248. account := &Account{ID: 9104, Platform: PlatformGrok, Type: AccountTypeOAuth}
  249. body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)
  250. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body)
  251. require.Zero(t, repo.tempUnschedCalls)
  252. }
  253. func TestHandleGrokAccountUpstreamError_Entitlement403Unchanged(t *testing.T) {
  254. repo := &grokQuotaAccountRepo{}
  255. svc := &OpenAIGatewayService{accountRepo: repo}
  256. account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth}
  257. before := time.Now()
  258. svc.handleGrokAccountUpstreamError(
  259. context.Background(), account, http.StatusForbidden, nil,
  260. []byte(`{"error":{"message":"subscription required"}}`),
  261. )
  262. require.Equal(t, 1, repo.tempUnschedCalls)
  263. require.Equal(t, "grok access or entitlement denied", repo.lastTempUnschedReason)
  264. require.Greater(t, repo.lastTempUnschedUntil, before.Add(29*time.Minute))
  265. require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute))
  266. }