| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- package service
- import (
- "context"
- "encoding/json"
- "net/http"
- "regexp"
- "strconv"
- "strings"
- "time"
- "github.com/tidwall/gjson"
- )
- // Grok upstream failure classes used to decide temp-unschedulable cooldowns and
- // pre-commit account failover. Classification is body-first so free-usage and
- // empty-output wording still win when the proxy rewrites status codes.
- type GrokUpstreamFailureClass string
- const (
- GrokFailureNone GrokUpstreamFailureClass = ""
- GrokFailureFreeUsage GrokUpstreamFailureClass = "subscription:free-usage-exhausted"
- GrokFailureBilling GrokUpstreamFailureClass = "billing_quota"
- GrokFailureEmptyUpstream GrokUpstreamFailureClass = "empty_upstream"
- GrokFailureModelCapacity GrokUpstreamFailureClass = "model_capacity"
- GrokFailureRateLimit GrokUpstreamFailureClass = "rate_limit"
- GrokFailureAuth GrokUpstreamFailureClass = "auth_error"
- GrokFailureServer GrokUpstreamFailureClass = "server_error"
- // GrokFailureCompatibility represents a request-history/body-shape that is
- // incompatible with the selected account or upstream replay contract. It
- // is account-independent: fail over, but never quarantine the pool.
- GrokFailureCompatibility GrokUpstreamFailureClass = "compatibility_error"
- )
- // GrokUpstreamFailureDecision is a pure classification result. Callers map it
- // onto existing account state helpers (tempUnscheduleGrok / rateLimitGrok).
- // BlockModel is retained for observability; the current scheduler does not
- // implement per-model soft-blocks, so free-usage deliberately never sets it.
- type GrokUpstreamFailureDecision struct {
- Class GrokUpstreamFailureClass
- Model string
- Cooldown time.Duration
- ShouldCooldown bool
- // ShouldFailover recommends trying another account before writing a
- // terminal response (pre-commit only). Content-policy rejections are
- // handled separately and never reach this classifier for failover.
- ShouldFailover bool
- // BlockModel is true only for empty-output when a model id is known.
- // Free-usage never sets this: the account cools, not a single model.
- BlockModel bool
- Reason string
- TokensActual *int64
- TokensLimit *int64
- }
- var (
- reGrokTokenPair = regexp.MustCompile(`(?i)tokens?\s*(?:\(actual\s*/\s*limit\))?\s*[:=]?\s*(\d+)\s*/\s*(\d+)`)
- reGrokModelFor = regexp.MustCompile(`(?i)(?:for\s+model|model|模型)\s*[::]?\s*([a-z0-9][a-z0-9._-]{2,80})`)
- )
- // classifyGrokUpstreamFailure decides cooldown/failover from status + body.
- // Priority (body/code first, status second):
- // 1. free-usage exhausted → account cool, no model block, failover
- // 2. billing hard quota → longer cool, failover
- // 3. empty model output → short cool + optional model soft-block marker, failover
- // 4. model capacity → short cool, failover
- // 5. bare rate-limit / 429 without free-usage language → cool, failover
- // 6. bare 5xx → brief cool, failover
- // 7. validation / client errors without quota language → no cool
- //
- // Content-policy 403s must be filtered by the caller before invoking this.
- func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedModel string) GrokUpstreamFailureDecision {
- text, code, low := grokUpstreamErrorCorpus(statusCode, responseBody)
- model := extractGrokFailureModel(text, responseBody, requestedModel)
- actual, limit, hasTokens := parseGrokTokenPair(text)
- if !hasTokens {
- actual, limit, hasTokens = parseGrokTokenPair(string(responseBody))
- }
- // --- Free usage / rolling quota exhausted ---
- if isGrokFreeUsageExhaustedText(low) || isGrokFreeUsageCode(code) || isGrokFreeUsageCode(text) {
- d := GrokUpstreamFailureDecision{
- Class: GrokFailureFreeUsage,
- Model: model,
- Cooldown: grokFreeUsageCooldownDuration(low),
- ShouldCooldown: true,
- ShouldFailover: true,
- BlockModel: false,
- Reason: firstNonEmpty(text, code, "free usage exhausted"),
- }
- if hasTokens {
- a, b := actual, limit
- d.TokensActual = &a
- d.TokensLimit = &b
- }
- return d
- }
- // Billing / hard quota (not free-tier rolling).
- // Cooldown stays at 30m to match the existing Grok 402/spending-limit
- // handler (longer cools would change ops behavior without a settings knob).
- if isGrokBillingQuotaText(low) || statusCode == http.StatusPaymentRequired {
- reason := firstNonEmpty(text, "billing quota")
- if statusCode == http.StatusPaymentRequired && text == "" {
- reason = "payment required"
- }
- return GrokUpstreamFailureDecision{
- Class: GrokFailureBilling,
- Model: model,
- Cooldown: 30 * time.Minute,
- ShouldCooldown: true,
- ShouldFailover: true,
- BlockModel: model != "",
- Reason: reason,
- }
- }
- // Responses replay/compaction payloads can be rejected by one Grok
- // deployment while the same request is valid on another account. Treat
- // these precise decoder/content-shape failures as account compatibility
- // errors, rather than durable account health failures.
- if isGrokCompatibilityError(statusCode, low, code) {
- return GrokUpstreamFailureDecision{
- Class: GrokFailureCompatibility,
- Model: model,
- ShouldFailover: true,
- ShouldCooldown: false,
- Reason: firstNonEmpty(text, "grok response compatibility error"),
- }
- }
- // Empty HTTP 200 / empty model output (often rewritten to synthetic 502).
- if isGrokEmptyModelOutputText(low) || isGrokEmptyModelOutputCode(code) {
- return GrokUpstreamFailureDecision{
- Class: GrokFailureEmptyUpstream,
- Model: model,
- Cooldown: 4 * time.Minute,
- ShouldCooldown: true,
- ShouldFailover: true,
- BlockModel: model != "",
- Reason: firstNonEmpty(text, "empty model output"),
- }
- }
- // Model capacity / overloaded.
- if isGrokModelCapacityText(low) {
- return GrokUpstreamFailureDecision{
- Class: GrokFailureModelCapacity,
- Model: model,
- Cooldown: time.Minute,
- ShouldCooldown: true,
- ShouldFailover: true,
- BlockModel: false,
- Reason: firstNonEmpty(text, "model capacity"),
- }
- }
- // Rate limit without free-usage language.
- if statusCode == http.StatusTooManyRequests || isGrokRateLimitText(low) {
- return GrokUpstreamFailureDecision{
- Class: GrokFailureRateLimit,
- Model: model,
- Cooldown: 10 * time.Minute,
- ShouldCooldown: true,
- ShouldFailover: true,
- BlockModel: false,
- Reason: firstNonEmpty(text, "rate limit"),
- }
- }
- // Upstream 5xx — brief cool. Empty-output synthetic 502 already handled above.
- if statusCode >= 500 && statusCode <= 599 {
- return GrokUpstreamFailureDecision{
- Class: GrokFailureServer,
- Cooldown: 2 * time.Minute,
- ShouldCooldown: true,
- ShouldFailover: true,
- Reason: firstNonEmpty(text, "server error"),
- }
- }
- return GrokUpstreamFailureDecision{Reason: text}
- }
- func grokUpstreamErrorCorpus(statusCode int, responseBody []byte) (text, code, low string) {
- _ = statusCode // classifier already has the transport status; corpus is body-only
- raw := strings.TrimSpace(string(responseBody))
- // Strip "upstream status NNN: ..." prefixes so free-usage / quota language is visible.
- if _, unwrappedBody, ok := unwrapGrokUpstreamErrorText(raw); ok {
- raw = unwrappedBody
- }
- text = raw
- codeFromJSON, msgFromJSON := parseGrokUpstreamErrorJSON(raw)
- if msgFromJSON != "" {
- if text == "" || len(msgFromJSON) > len(text)/2 || looksLikeGrokQuotaMessage(msgFromJSON) {
- text = msgFromJSON
- }
- }
- // Prefer structured fields from the original body when present.
- if len(responseBody) > 0 {
- if m := strings.TrimSpace(firstNonEmpty(
- gjson.GetBytes(responseBody, "error.message").String(),
- gjson.GetBytes(responseBody, "message").String(),
- gjson.GetBytes(responseBody, "error").String(),
- )); m != "" && (text == "" || looksLikeGrokQuotaMessage(m)) {
- text = m
- }
- if c := strings.TrimSpace(firstNonEmpty(
- gjson.GetBytes(responseBody, "error.code").String(),
- gjson.GetBytes(responseBody, "code").String(),
- )); c != "" {
- codeFromJSON = c
- }
- }
- code = codeFromJSON
- low = strings.ToLower(strings.TrimSpace(text))
- if code != "" && !strings.Contains(low, strings.ToLower(code)) {
- low = strings.ToLower(code) + " " + low
- }
- return text, code, low
- }
- func unwrapGrokUpstreamErrorText(errText string) (status int, body string, ok bool) {
- text := strings.TrimSpace(errText)
- if text == "" {
- return 0, "", false
- }
- lower := strings.ToLower(text)
- for _, p := range []string{"upstream status ", "status "} {
- if !strings.HasPrefix(lower, p) {
- continue
- }
- rest := strings.TrimSpace(text[len(p):])
- i := 0
- for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' {
- status = status*10 + int(rest[i]-'0')
- i++
- }
- if status <= 0 || i == 0 {
- return 0, "", false
- }
- rest = strings.TrimSpace(rest[i:])
- if strings.HasPrefix(rest, ":") {
- rest = strings.TrimSpace(rest[1:])
- }
- return status, rest, true
- }
- return 0, "", false
- }
- func parseGrokUpstreamErrorJSON(errText string) (code, message string) {
- text := strings.TrimSpace(errText)
- if text == "" || text[0] != '{' {
- return "", ""
- }
- var payload map[string]any
- if json.Unmarshal([]byte(text), &payload) != nil {
- return "", ""
- }
- if v, ok := payload["code"].(string); ok {
- code = v
- }
- if v, ok := payload["message"].(string); ok {
- message = v
- }
- if errObj, ok := payload["error"].(map[string]any); ok {
- if v, ok := errObj["code"].(string); ok && code == "" {
- code = v
- }
- if v, ok := errObj["message"].(string); ok && message == "" {
- message = v
- }
- }
- if errStr, ok := payload["error"].(string); ok && message == "" {
- message = errStr
- }
- return strings.TrimSpace(code), strings.TrimSpace(message)
- }
- func looksLikeGrokQuotaMessage(s string) bool {
- low := strings.ToLower(s)
- return strings.Contains(low, "quota") ||
- strings.Contains(low, "usage") ||
- strings.Contains(low, "credit") ||
- strings.Contains(low, "额度") ||
- strings.Contains(low, "free")
- }
- func isGrokFreeUsageCode(code string) bool {
- c := strings.ToLower(strings.TrimSpace(code))
- if c == "" {
- return false
- }
- if strings.Contains(c, "subscription:free-usage-exhausted") ||
- strings.Contains(c, "free-usage-exhausted") ||
- strings.Contains(c, "free_usage_exhausted") ||
- strings.Contains(c, "usage-limit-exceeded") ||
- strings.Contains(c, "usage_limit_exceeded") {
- return true
- }
- return (strings.Contains(c, "free-usage") || strings.Contains(c, "free_usage")) &&
- (strings.Contains(c, "exhaust") || strings.Contains(c, "exceed") || strings.Contains(c, "limit"))
- }
- func isGrokFreeUsageExhaustedText(low string) bool {
- if low == "" {
- return false
- }
- if strings.Contains(low, "free-usage-exhausted") ||
- strings.Contains(low, "free_usage_exhausted") ||
- strings.Contains(low, "subscription:free-usage") ||
- strings.Contains(low, "usage-limit-exceeded") ||
- strings.Contains(low, "usage_limit_exceeded") ||
- strings.Contains(low, "free-tier-limit") ||
- strings.Contains(low, "free_tier_limit") {
- return true
- }
- if strings.Contains(low, "free usage") ||
- strings.Contains(low, "included free usage") ||
- strings.Contains(low, "used all the included free") ||
- strings.Contains(low, "you've used all the included free") ||
- strings.Contains(low, "you have used all the included free") ||
- strings.Contains(low, "free quota") ||
- strings.Contains(low, "no remaining free") ||
- strings.Contains(low, "out of free") ||
- strings.Contains(low, "usage resets over a rolling") ||
- (strings.Contains(low, "free tier") && (strings.Contains(low, "exhaust") || strings.Contains(low, "limit") || strings.Contains(low, "exceed"))) {
- return true
- }
- for _, p := range []string{
- "额度耗尽", "额度用完", "额度不足", "额度已用尽", "额度已耗尽",
- "免费额度", "免费用量", "用量用完", "用量耗尽", "用量超限", "用量已用尽",
- "配额耗尽", "配额已用尽", "配额不足", "配额超限", "配额用完",
- "没有额度", "没额度", "无额度", "可用额度不足", "模型额度",
- "临时额度", "额度已满", "额度超限", "额度达到上限",
- "模型额度用完", "模型额度耗尽", "账号额度用完", "账号额度耗尽",
- "额度不够", "没额度了", "额度没了", "用完额度", "耗尽额度",
- } {
- if strings.Contains(low, p) {
- return true
- }
- }
- if (strings.Contains(low, "quota") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed") || strings.Contains(low, "limit"))) ||
- (strings.Contains(low, "usage") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed")) && (strings.Contains(low, "limit") || strings.Contains(low, "free") || strings.Contains(low, "model"))) {
- if strings.Contains(low, "free") || strings.Contains(low, "rolling") ||
- strings.Contains(low, "24-hour") || strings.Contains(low, "24 hour") ||
- strings.Contains(low, "model") || strings.Contains(low, "subscription") ||
- strings.Contains(low, "included") || strings.Contains(low, "tokens") {
- return true
- }
- }
- if a, b, ok := parseGrokTokenPair(low); ok && b > 0 && a >= b {
- if strings.Contains(low, "free") || strings.Contains(low, "subscription") ||
- strings.Contains(low, "included") || strings.Contains(low, "model") ||
- strings.Contains(low, "usage") || strings.Contains(low, "quota") ||
- strings.Contains(low, "rolling") {
- return true
- }
- }
- return false
- }
- func isGrokBillingQuotaText(low string) bool {
- if low == "" {
- return false
- }
- if strings.Contains(low, "insufficient_quota") {
- return true
- }
- if strings.Contains(low, "billing") && strings.Contains(low, "quota") {
- return true
- }
- if strings.Contains(low, "payment") && (strings.Contains(low, "required") || strings.Contains(low, "fail")) {
- return true
- }
- if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") ||
- (strings.Contains(low, "need a grok subscription") || strings.Contains(low, "need grok subscription")) {
- return true
- }
- if strings.Contains(low, "余额不足") || strings.Contains(low, "欠费") || strings.Contains(low, "需要付费") {
- return true
- }
- return false
- }
- // grokRetryableOnSameAccount marks transient 429 classes for the shared
- // failover loop. Capacity and ordinary throttles are request/model pressure,
- // not evidence that the credential is invalid, so a bounded retry on the same
- // account is preferable before switching accounts. Free-usage and billing
- // exhaustion deliberately skip same-account retry and fail over immediately.
- func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody []byte) bool {
- if account == nil || !account.IsGrok() {
- return false
- }
- decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
- switch decision.Class {
- case GrokFailureFreeUsage, GrokFailureBilling, GrokFailureCompatibility:
- // Quota/entitlement exhaustion is account state, not transient
- // pressure. Retrying the same account only repeats the failure.
- return false
- case GrokFailureModelCapacity:
- if statusCode == http.StatusTooManyRequests {
- return true
- }
- }
- return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)
- }
- func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time, int) {
- if !grokRetryableOnSameAccount(account, statusCode, responseBody) {
- return false, 0, time.Time{}, 0
- }
- decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
- if decision.Class != GrokFailureModelCapacity {
- return true, 0, time.Time{}, 0
- }
- // The error is reconstructed after every upstream attempt, so a deadline
- // stored on the error cannot provide a request-wide window. Cap capacity
- // retries explicitly to one replay; this remains effective even when the
- // first attempt itself takes longer than the nominal 30-second window.
- return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second), 1
- }
- // shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account
- // overlay. Model-capacity responses are request pressure, not a team quota;
- // marking them would hide healthy sibling credentials while the bounded
- // same-account retry is still in progress. Ordinary 429s and free-usage
- // exhaustion retain the existing quota/team isolation behavior.
- func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool {
- decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
- if decision.Class == GrokFailureModelCapacity {
- return false
- }
- return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage
- }
- func isGrokCompatibilityError(statusCode int, low, code string) bool {
- if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity {
- return false
- }
- combined := strings.ToLower(strings.TrimSpace(low + " " + code))
- // Compaction blobs are account/session-bound and frequently fail with 400
- // or 422 after a reconnect. Also cover xAI's JSON decoder shape errors.
- for _, phrase := range []string{
- "could not decode the compaction blob",
- "cannot decode the compaction blob",
- "decode the compaction blob",
- "ensure it is unmodified from the compact response",
- "compaction blob",
- } {
- if strings.Contains(combined, phrase) {
- return true
- }
- }
- for _, marker := range []string{
- "invalid_compaction",
- "compaction_decode_error",
- } {
- if strings.Contains(combined, marker) {
- return true
- }
- }
- return false
- }
- func isGrokModelCapacityText(low string) bool {
- return strings.Contains(low, "capacity") ||
- strings.Contains(low, "overloaded") ||
- strings.Contains(low, "server_busy") ||
- strings.Contains(low, "too many concurrent") ||
- strings.Contains(low, "engine_overloaded")
- }
- func isGrokRateLimitText(low string) bool {
- return strings.Contains(low, "rate limit") ||
- strings.Contains(low, "rate_limit") ||
- strings.Contains(low, "too many requests") ||
- strings.Contains(low, "请求过于频繁") ||
- strings.Contains(low, "速率限制")
- }
- func isGrokEmptyModelOutputText(low string) bool {
- if low == "" {
- return false
- }
- return strings.Contains(low, "empty model output") ||
- strings.Contains(low, "no content/tool_calls") ||
- strings.Contains(low, "no client-visible content") ||
- strings.Contains(low, "empty_upstream") ||
- strings.Contains(low, "empty upstream")
- }
- func isGrokEmptyModelOutputCode(code string) bool {
- c := strings.ToLower(strings.TrimSpace(code))
- if c == "" {
- return false
- }
- return c == "empty_upstream" ||
- c == "empty-model-output" ||
- c == "empty_model_output" ||
- strings.Contains(c, "empty_upstream") ||
- strings.Contains(c, "empty-model-output")
- }
- func grokFreeUsageCooldownDuration(low string) time.Duration {
- // "rolling 24-hour" describes the upstream usage window, not a cooldown
- // that starts when this proxy observes a 429. Without an upstream reset
- // timestamp we cannot know when the oldest usage exits that window, so use
- // a short probe interval and let a successful probe clear the block.
- return grokFreeUsageProbeCooldown
- }
- const grokFreeUsageProbeCooldown = 10 * time.Minute
- func parseGrokTokenPair(errText string) (actual, limit int64, ok bool) {
- m := reGrokTokenPair.FindStringSubmatch(errText)
- if len(m) != 3 {
- return 0, 0, false
- }
- a, errA := strconv.ParseInt(m[1], 10, 64)
- b, errB := strconv.ParseInt(m[2], 10, 64)
- if errA != nil || errB != nil {
- return 0, 0, false
- }
- return a, b, true
- }
- func extractGrokFailureModel(text string, responseBody []byte, fallback string) string {
- if m := reGrokModelFor.FindStringSubmatch(text); len(m) == 2 {
- return normalizeGrokFailureModelID(m[1])
- }
- if len(responseBody) > 0 {
- if m := strings.TrimSpace(firstNonEmpty(
- gjson.GetBytes(responseBody, "error.model").String(),
- gjson.GetBytes(responseBody, "model").String(),
- )); m != "" {
- return normalizeGrokFailureModelID(m)
- }
- }
- return normalizeGrokFailureModelID(fallback)
- }
- // normalizeGrokFailureModelID trims whitespace and trailing punctuation the
- // "for model X." extractors sometimes capture.
- func normalizeGrokFailureModelID(model string) string {
- model = strings.TrimSpace(model)
- model = strings.TrimRight(model, ".,;:!?")
- return strings.TrimSpace(model)
- }
- // applyGrokUpstreamFailureDecision maps a classification onto existing account
- // health helpers. Returns true when the decision fully handled the error path
- // (caller should not apply the status-code switch defaults again).
- func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision(
- ctx context.Context,
- account *Account,
- decision GrokUpstreamFailureDecision,
- ) bool {
- if s == nil || account == nil || !decision.ShouldCooldown || decision.Cooldown <= 0 {
- return false
- }
- // Keep reasons short and stable for ops UI / temp_unschedulable_reason.
- var reason string
- switch decision.Class {
- case GrokFailureFreeUsage:
- reason = "grok free usage exhausted"
- // Model-scoped free usage: soft-block only the named model so other
- // models on the same account remain pickable (grok2api ModelQuotaBlock).
- low := strings.ToLower(decision.Reason)
- if decision.Model != "" && isGrokModelSpecificFreeUsage(low, decision.Model) {
- until := time.Now().Add(decision.Cooldown)
- markGrokModelQuotaBlock(account.ID, decision.Model, until)
- // The upstream explicitly scoped exhaustion to this model, so an
- // account-wide cool would incorrectly take healthy sibling models out
- // of rotation.
- return true
- }
- case GrokFailureBilling:
- low := strings.ToLower(decision.Reason)
- if strings.Contains(low, "spending") || strings.Contains(low, "credits") {
- // Spending-limit/credit exhaustion is a billing-window condition. Keep
- // the account recoverable and let the normal rate-limit recovery clear it.
- s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now()))
- return true
- }
- // Keep the historical 402/payment reason for ops UI + regression tests.
- reason = "grok payment required"
- case GrokFailureEmptyUpstream:
- reason = "grok empty model output"
- case GrokFailureModelCapacity:
- // Capacity is scoped to the requested model. Never persist an account-wide
- // unschedulable state for this transient class; the failover loop performs
- // a bounded same-account retry before selecting another account.
- _ = persistGrokTransientModelCooldown(account, decision)
- return true
- case GrokFailureRateLimit:
- // Pure 429 without free-usage language keeps the existing rate-limit
- // snapshot path (Retry-After / quota headers). Body-only rate-limit
- // phrasing still cools here via ShouldCooldown from the classifier, but
- // the handler only invokes this for non-RateLimit classes.
- return false
- case GrokFailureServer:
- reason = "grok upstream temporary error"
- case GrokFailureCompatibility:
- // Deliberately no account mutation. The caller uses ShouldFailover to
- // retry another account; cooling a pool for a request-shape mismatch
- // would remove healthy accounts.
- return true
- default:
- return false
- }
- s.tempUnscheduleGrok(ctx, account, decision.Cooldown, reason)
- return true
- }
|