Repository navigation
Description
Description
Summary
A provider's genuine, transient 429 rate-limit response — one that sets retry-after and x-ratelimit-* correctly — is misclassified as QuotaExceededError instead of RateLimitError when the error message contains the substring "limit exhausted". Because QuotaExceededError is non-retryable (isRetryable → false), the client aborts instead of honoring retry-after and waiting out the cooldown — permanently halting agent runs under concurrency.
This is the false-positive counterpart of the same classifier issue discussed in #32120 (which routes real quota 429s into the non-retryable bucket to avoid burning user quota windows). The message-text heuristic meant to catch subscription/quota 429s is collateral-damaging legitimate rate limits that happen to share a phrase.
Evidence
Provider (gwdg SAIA, OpenAI-compatible) returns a fully spec-compliant 429, with correct status, type, retry-after, and x-ratelimit-*:
HTTP/2 429
retry-after: 4
x-ratelimit-limit-requests-10s: 3
x-ratelimit-remaining-requests-10s: 0
x-ratelimit-reset-requests-10s: 14
{"error":{"message":"User 10s request rate limit exhausted (3 of 3).",
"type":"rate_limit_exceeded","param":null,"code":null}}Root cause
In provider-error.ts, the 429 disambiguation checks QUOTA_TEXT before the rate-limit branch:
// ~line 169
const QUOTA_TEXT =
/insufficient[-_\s]?(?:quota|balance)|quota[-_\s]?exceeded|budget exceeded|usage limit|limit exhausted|package has expired|plan does not yet include/i
// ~lines 262-266 — evaluated FIRST
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text)) // 'limit exhausted' matches here
)
return new QuotaExceededError(details) // non-retryable
// ~lines 272-281 — evaluated SECOND, never reached for this 429
if (
input.status === 429 || // a 429 alone SHOULD hit this
codes.some((code) => code.includes("rate_limit") || /* ... */) ||
RATE_LIMIT_TEXT.test(text)
)
return new RateLimitError({ ... retryAfterMs ... }) // retryable, honors retry-afterQUOTA_TEXT's alternative limit exhausted (intended for billing/quota messages) catches the generic rate-limit message "request rate limit exhausted". Then isRetryable() (model-retry.ts, case "QuotaExceeded": return false) aborts instead of honoring retry-after.
The regression is triggered purely by incidental provider message wording: this provider previously worded it "rate limit exceeded" (→ RATE_LIMIT_TEXT, correctly RateLimit); a server-side wording change to "rate limit exhausted" now trips QUOTA_TEXT. Client behavior should not depend on free-text phrasing.
Why it matters (vs. #32120)
#32120 argues real quota-429s shouldn't be retried. But that design relies on a message-text heuristic to distinguish quota from rate limit, and a legitimate rate limit with retry-after set has no business in the quota bucket. Every authoritative source keys rate-limit vs quota off status/code/type/retry-after, not message wording. OpenAI's own docs: "Check the error message and error.code". This response even sets type: "rate_limit_exceeded", which the classifier ignores in favor of the text.
Suggested fix
- Respect
retry-after/retry-after-ms: if a 429 setsretry-after(or carriesx-ratelimit-*, or a structuredcode/typeindicating rate limit), classify asRateLimit. A quota exhaustion never advertises a retry-after. Only fall through toQuotaExceededfor 429s that are clearly quota (a quota-specificcode, or billing phrasing without retry-after). - Tighten
QUOTA_TEXT:limit exhaustedis too broad. Bind it to a quota context ("monthly limit", "usage limit", "plan", "billing", "credit") or drop it and rely onQUOTA_CODES+ billing-specific phrases. - Prefer the structured
error.type/error.codefields over message text where they're authoritative.
Plugins
OMO-Slim
OpenCode version
v2.0.32
Steps to reproduce
Interpose the headers/body above on any 429 (or point a scratch provider at a server returning it), then:
opencode run -m <model> "hi" # → Error, no retryDeterministic on classification; confirm via opencode run headless (a proxy returning exactly the above 429 reproduces it).
Screenshot
No response
Operating System
No response
Terminal
No response
Activity
opencodejr commentedon Oct 27, 2026
Thanks for the detailed report. I reproduced this on 2.0.32, and it still happens on the latest
dev(fd8268ea5).I pointed a custom
@ai-sdk/openai-compatibleprovider at a mock server that returns your 429 (type: "rate_limit_exceeded",retry-afterandx-ratelimit-*headers), then ranopencode run -m mock/m "hi":User 10s request rate limit exhausted (3 of 3)., the run fails at once with that error and doesn't wait forretry-after.rate limit exceeded, OpenCode retries with backoff as expected.This matches your analysis: in
packages/ai/src/provider-error.ts, thelimit exhaustedalternative inQUOTA_TEXTis checked before the rate-limit branch, so the error is classified as a non-retryable quota error. The script below reproduces both cases.Related: #47685 (quota/402 classification relying on message text, the opposite direction).
Reproduction script (repro.sh)
Run it:
bash repro.sh