429 with insufficient_quota is classified as terminal QUOTA and never retried, breaking gateway/relay API users #3338
Replies: 3 comments
|
Confirmed the mechanism you describe, with one correction to the proposed fix and a primary-source payload that I think changes the shape of the right solution. Your root cause is right
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
if (status === 429) return 'RATE_LIMIT'
Your deployment can be fixed today, without a default change
# $DSH_HOME/profiles/<name>/cordis.patch.yml
- id: llm-deepseek
config:
# an id-targeted patch replaces the entry's whole config — restate your other fields
retryPolicy:
mode: normal # required by the schema
retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, QUOTA]Verified against Why
|
| provider | terminal exhaustion wording | status |
|---|---|---|
| DeepSeek | Insufficient Balance |
402 |
| OpenAI | insufficient_quota / You exceeded your current quota… |
429 |
| Anthropic | Your credit balance is too low to access the API… |
400 |
402, 429 and 400 all mean "terminal" somewhere, and 429 means both things depending on the body. Two consequences:
httpErrorCodehas no 402 branch at all (grep -rn "\b402\b" packages/llm→ no match). The record above is classified correctly only because the message text happens to match/\binsufficient[\s_-]+(?:quota|balance|credits?)\b/; the 402 contributed nothing. A wording change upstream turns it intoHTTP_402.- Anthropic's wording is missed today —
credit balance is too lowhas the noun but no exhaustion verb, so none of the five patterns fire.
The classifier also errs in the opposite direction
Rate limits are commonly expressed as quota. Google-style APIs and OpenAI-compatible relays emit wording like:
Quota exceeded for quota metric 'Generate Content API requests per minute'
This matches /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/ → QUOTA → terminal, never retried. Same bug as yours, opposite direction, and it survives whatever the retry set does. The root problem is that quota denotes both an account balance and a rate allowance, and the five patterns treat them as one thing.
Multi-clause detail makes it worse. detail is [code, type, message].join(' '), so a single string can carry both readings — Rate limit exceeded. Your quota resets in one minute. — and any proximity-based rule that ignores clause boundaries will call that terminal.
A proximity-only fallback is not enough
The obvious repair for the missed wording is a fallback that pairs a quota noun with an exhaustion marker inside a token window. I tried exactly that on rc.7 (window 8, tokens split on [\s,_-]+, whole detail string) and measured it against a 19-case matrix. It fixes insufficient account balance and keeps all seven existing assertions in service.spec.ts green, but three cases fail:
| detail | expected | proximity-only |
|---|---|---|
Your credit balance is too low to access the API |
terminal | missed (noun present, no exhaustion verb) |
Rate limit exceeded. Your quota resets in one minute. |
transient | terminal — exceeded and quota are 2 tokens apart across a sentence boundary |
Quota exceeded for quota metric '… requests per minute' |
transient | terminal |
The second row is a new failure the fallback introduces, and it is the expensive direction. It survives a negative-case suite made of single-clause strings (rate limit reached, quota resets in one minute, context window exceeded) because each of those lacks one of the two token classes — such negatives verify the rule's definition rather than its risk surface. Clause scoping and a transient veto are what actually contain it.
Proposed shape
Structured signal first, prose last, and bias unknown toward retryable:
- T0 — exact
code/typeenums (insufficient_quota,insufficient_balance,credit_balance_too_low,billing_hard_limit_reachedvsrate_limit_exceeded,overloaded_error). These are provider-maintained values and deserve exact matching; joining them into one string with free prose discards that precision. - T1 — unambiguous statuses: 402 →
QUOTA. - T2 — prose, split into wording that has no transient reading (
insufficient balance,balance exhausted/depleted,out of credits,balance is too low,run out of credits) and wording that does (quota exceeded/reached), where the ambiguous half is vetoed by transient markers in the same clause (retry,try again,resets,per minute/hour/day,RPM/TPM,too many requests,slow down,overloaded). Clause-scoped, window 4 tokens. Deliberately no status numbers in the veto list, since OpenAI ships terminalinsufficient_quotaon 429. - T3 — fall through to the status default (429 →
RATE_LIMIT).
Prose layer, same signature as today (drop-in for isQuotaExceededError)
const HARD_TERMINAL_RE = [
/\binsufficient[\s_-]+(?:quota|balance|credits?|funds?)\b/i,
/\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted|drained)\b/i,
/\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
/\b(?:balance|credits?|funds?)\b[^.;]{0,24}\bis[\s_-]+too[\s_-]+low\b/i,
/\brun(?:s|ning)?[\s_-]+out[\s_-]+of[\s_-]+(?:credits?|quota|balance|funds?)\b/i,
/\bno[\s_-]+(?:remaining|available)[\s_-]+(?:credits?|quota|balance|funds?)\b/i,
]
const SOFT_TERMINAL_RE = [
/\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i,
/\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i,
]
const TRANSIENT_VETO_RE =
/\b(?:retry|try[\s_-]+again|resets?|slow[\s_-]+down|too[\s_-]+many[\s_-]+requests|overloaded|per[\s_-]+(?:second|minute|hour|day)|[rt]pm|requests?[\s_-]+per)\b/i
export function isQuotaExceededError(detail: string): boolean {
return detail.split(/[.;\n!]/).some((clause) => {
if (clause.trim() === '') return false
if (HARD_TERMINAL_RE.some(re => re.test(clause))) return true // never vetoed
if (TRANSIENT_VETO_RE.test(clause)) return false
return SOFT_TERMINAL_RE.some(re => re.test(clause)) || coOccursInClause(clause)
})
}coOccursInClause pairs a quota noun (quota|balance|credits?|funds?|allowance|budget) with an exhaustion marker (insufficient|exhausted|depleted|drained|exceed(ed|s)?) within 4 tokens of the same clause, which is what catches wording like insufficient account balance that the fixed patterns miss.
Checked against 19 cases: the 7 assertions currently in packages/llm/llm/tests/service.spec.ts are unchanged, plus DeepSeek 402, Anthropic too low, insufficient account balance, Insufficient balance, please top up and try again (hard wording must beat the veto), and the transient set above including Rate limit exceeded. Your quota resets in one minute. and the requests per minute quota wording.
Related reports
- Bug: pi-ai adapter misclassifies 401/403 in error text as AUTH, surfacing misleading "API key is invalid" #3073 — pi-ai adapter misclassifies
401/403appearing in error text asAUTH - 【BUG反馈】额度耗尽(403)被统一显示成 "API key is invalid" #1631 — quota exhaustion (403) surfaced with the wrong message
- Fix: classify 401/403 bodies by semantics (context overflow / quota) instead of always AUTH #1127 — classify 401/403 bodies by semantics
These four are the same root pattern: provider error semantics inferred from prose. classifyPiAiError says so in its own comment — "we are left pattern-matching terse words here" — because pi-ai flattens the error to message upstream. That path genuinely has only text, so the prose layer has to be precise on its own; the deepseek path, by contrast, already receives code, type and status and currently throws the distinction away by joining them.
Happy to break the T0/T1 part out into its own thread if that is easier to act on. Aware that external PRs are not being accepted right now, so this is a proposal rather than a patch.
你定位的顺序问题是对的:httpErrorCode 里文本分类跑在 status === 429 之前,classifyPiAiError 同样如此,而 QUOTA 不在 DEFAULT_RETRYABLE_CODES 里。
但两个建议我认为方向不对:
- 你的场景不需要改默认值。
retryPolicy.retryableCodes本来就是 provider 配置,在自己的cordis.patch.yml里把QUOTA加进去即可(mode: normal是 schema 必填,漏了会被 union 拒绝;我在 rc.7 上验证过)。把QUOTA加进全局默认,等于让所有真·余额耗尽都变成 3 次尝试,并削弱其它消费方依赖的终态语义——QUOTA是分类,不是策略。 - 「400/403 表示真耗尽」不成立。 各家状态码不一致:DeepSeek 是 402(我本机 session 日志实录
{"code":"QUOTA","status":402}),OpenAI 的insufficient_quota走 429,Anthropic 的credit balance is too low走 400。
顺带两个现存缺陷:httpErrorCode 完全没有 402 分支(上面那条判对纯粹是靠正则匹到了 insufficient balance 字符串);而 Quota exceeded for quota metric '... requests per minute' 这类速率限制会被判成终态,方向和你的问题正好相反。
建议的形状是分层:code/type 枚举精确匹配 → 无歧义状态码(402)→ 散文(拆成"无瞬时读法"和"有歧义"两类词表,后者受同子句瞬时标记否决)→ 兜底偏向可重试。
|
Subject: Re: 429/insufficient_quota misclassification on relay gateways — confirmed + one residual Hello, Thank you for the detailed root-cause analysis. It is accurate and we have since implemented the fixes you outlined, with one residual issue for your awareness. What we confirmed (matching your diagnosis): httpErrorCode (llm-deepseek) and classifyPiAiError (llm-pi-ai) both ran the prose quota classifier before consulting the HTTP status, so a text match outranked a 429, and QUOTA was not in DEFAULT_RETRYABLE_CODES. Added QUOTA to the provider's retryableCodes via the profile retryPolicy config, per your recommendation — this survives source upgrades since it lives in ~/.dsh/. One residual we'd appreciate guidance on: A 429001 "inference tpm exhausted" occasionally takes longer to recover than our (10-minute-budgeted) retry window, and we saw a single case where the request gave up at 10/10 (60s delays). Since TPM recovery on a shared relay is governed by aggregate usage and is inherently unpredictable, a purely retry-count-based budget cannot guarantee coverage. Best regards |
|
Your root-cause holds for the official chain, and that's the right place for it to be fixed. Until it is, there is a parallel path whose retry behavior we have now actually measured, so posting the numbers here in case it unblocks gateway/relay users today. Through pi2dsh, a transport-carrying Pi provider package rides pi-ai's own transport retry (
Harness and raw verdicts are in the repo: Scope honesty: this is a transport-layer verdict (crafted 429/500 responses into the real client/retry code), not an end-to-end run against your specific relay — and it does not fix the official |
Uh oh!
There was an error while loading. Please reload this page.
Environment: latest checkout, running via
node --import tsx/esm apps/cli/src/bin.ts webProblem:
A 429 response whose body contains
code: "insufficient_quota"/"Allocated quota exceeded"is mapped to the terminal codeQUOTAbyhttpErrorCode()inpackages/llm/llm-deepseek/src/adapter.ts(theisQuotaExceededErrorcheck runs BEFORE thestatus === 429branch). BecauseQUOTAis not inDEFAULT_RETRYABLE_CODESinpackages/llm/llm/src/retry-policy.ts, llm-retry skips retrying entirely — a single transient 429 kills the whole turn.Why it matters:
Many OpenAI-compatible gateways/relays (one-api, new-api, chatanywhere, etc.) return
429 insufficient_quotafor TRANSIENT rate/allowance throttling, not just for truly exhausted balances. For those users every burst of requests fails with no retry, making the harness unusable under rate limits.Repro:
insufficient_quotaon burstSuggested fix:
'QUOTA'toDEFAULT_RETRYABLE_CODES(worked for us)httpErrorCode, only classify as QUOTA when it is a 4xx/credit-style exhaustion (e.g. 400/403 semantics), and letstatus === 429take precedence for RATE_LIMITAll reactions