fix(kosong): fail fast on quota-exhausted 429 instead of retrying - #1857
Conversation
A 429 caused by an exhausted account quota or insufficient balance (Moonshot error.type "exceeded_current_quota_error", OpenAI "insufficient_quota") can never succeed on retry, yet it was classified as APIProviderRateLimitError and silently retried for the whole budget (10 attempts, ~3 minutes of backoff) with no UI feedback — the session appeared frozen on every request. Introduce APIProviderQuotaExhaustedError, minted in normalizeAPIStatusError from the structured body error.type/error.code forwarded by convertOpenAIError, with billing-anchored message patterns as a fallback for gateways that flatten the body to text. The new class is excluded from isRetryableGenerateError (fail fast, even when a retry-after header is present) and from isProviderRateLimitError (no swarm requeue/suspend). toKimiErrorPayload and translateProviderError map it to provider.api_error (retryable: false) instead of provider.rate_limit, and classifyApiError reports it as quota_exhausted in telemetry. agent-core-v2 mirrors the same fix. Transient rate-limit 429s keep the existing retry, backoff, and Retry-After behavior (verified end-to-end against a mock provider: quota body fails after attempt 1/10; rate-limit body still walks the full 10-attempt ladder). Behavior changes to note: quota-failed swarm subagents now fail instead of suspending indefinitely as "Rate limited...", and quota errors cross the wire as provider.api_error rather than provider.rate_limit.
🦋 Changeset detectedLatest commit: 2aa7918 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f585cbb4f7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Responses response.failed / error SSE events carry no HTTP status and were minted by errorFromOpenAIResponsesEvent as either a rate-limit error (rate_limit_exceeded / embedded status_code=429) or a base ChatProviderError — and the base class falls into the retryable unclassified-failure fallback, so an insufficient_quota event still burned the whole retry budget on the openai_responses path. Route the event code and message through the same quota-exhausted check before the rate-limit branch, in kosong and the agent-core-v2 mirror. Covers all three entry paths (error events, response.failed, nested gateway frames) since they share the single converter.
…rule agent-core-v2 comments live solely in the top-of-file block, never beside functions or statements; the kosong twins keep the full rationale.
|
Pushed two follow-up commits after a local review pass:
|
|
Hi @wbxl2000 , PTAL 🙏 — this one bit us for real: our quota ran out and every prompt turned into what looked like a ~3-minute freeze. I attached the Node inspector to the Fixes: #1857 (fail fast on quota-exhausted 429) and #1861 (show retry progress in |
…fast # Conflicts: # docs/en/configuration/config-files.md # docs/zh/configuration/config-files.md # packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts # packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts
|
Hi @RealKai42 @sailist — merged latest main and resolved the conflicts from the #1970 restructure: the v2 changes now live under |
|
Suggestion: move the quota-exhausted classification into the Thanks for the thorough fix — the fail-fast semantics and the structured-code-first classification are clearly the right behavior. I'd like to suggest a different placement for the classification logic, though. The knowledge this PR encodes — Moonshot's The split I'd propose follows the existing layering: the error type is generic, the error conversion is not.
Two places need explicit wiring so nothing regresses: the This keeps |
Per review on MoonshotAI#1857: the knowledge of how a backend signals quota exhaustion is vendor-specific and must not run for every OpenAI-compatible provider from the shared conversion layer. - Add a convertError hook: ProtocolTrait.convertError in agent-core-v2 (single-value, last-declarer-wins, bound by composeOpenAIChatHooks / composeAnthropicHooks / traitConvertError) and an equivalent optional hook parameter on convertOpenAIError / convertAnthropicError. Bases consult it with the raw failure (SDK error on HTTP paths, raw event on the Responses in-stream path) after the abort guard, before their own rules. - Declare Moonshot's quota signals (exceeded_current_quota_error, billing wordings) on the Kimi side: kimiOpenAITrait and kimiAnthropicTrait in v2, the KimiChatProvider and KimiFiles catch sites in kosong, all through the new classifyKimiQuotaError. - Drop the options parameter from normalizeAPIStatusError and the shared quota code/pattern tables: the contract layer keeps only the vendor-neutral APIProviderQuotaExhaustedError type and its retry / rate-limit / wire-mapping semantics. - The OpenAI bases keep recognizing only OpenAI's own documented insufficient_quota code (HTTP and Responses stream events) as protocol knowledge of that wire. Behavior: kimi and openai provider types classify exactly as before; an unregistered vendor speaking Moonshot billing wordings through a plain openai transport now stays a retryable rate limit by design.
Follow-up to the second review round on MoonshotAI#1857, all four findings: - Kimi-over-Anthropic (legacy engine): AnthropicOptions gains the same optional convertError hook as the OpenAI bases, threaded through AnthropicStreamedMessage and every catch site, and the provider manager's anthropic route now passes classifyKimiQuotaError for provider type kimi — a quota-exhausted 429 over this transport previously still burned the retry budget. classifyKimiQuotaError now also walks error -> .error -> .error.error for the code/type, since the Anthropic SDK keeps the full body on .error instead of hoisting. - v2 telemetry: ApiErrorKind gains 'quota_exhausted' and classifyApiError checks APIProviderQuotaExhaustedError before the generic 429 branch, matching the legacy engine's reporting. - Hook contract: converted ChatProviderErrors now pass through before the vendor hook is consulted in convertOpenAIError / convertAnthropicError (both engines), so the hook sees each raw failure exactly once even when a stream-minted error crosses an outer catch; tests assert the single consult. - protocolTrait: the convertError member doc shrinks to the concise style and the consult contract moves into the file header's composition rules.
Third review round on MoonshotAI#1857: - Fix the v2 anthropic base header and AnthropicHooks doc still claiming withThinking is the only hook. - Drop the two remaining non-header JSDoc blocks in protocolTrait.ts per the AGENTS.md header-only rule; the consult contract already lives in the file header. - Update the ProtocolTrait contract test to the seventeen-hook shape (convertError included) and cover the traitConvertError binding. - Add real-assembly regression probes: the v2 registry composes a (kimi, anthropic) provider whose mocked SDK client throws a Moonshot quota 429 and generate rejects with the non-retryable APIProviderQuotaExhaustedError (a plain anthropic composition keeps the same 429 retryable); the legacy ProviderManager routing test asserts convertError is classifyKimiQuotaError on the kimi-anthropic route and absent for plain anthropic; the legacy provider threads options.convertError to its generate catch.
…docs Fourth review round on MoonshotAI#1857: - Drop the AnthropicHooks member JSDoc (its content already lives in the anthropic.ts and anthropicHooks.ts file headers) and fix the anthropic contrib header still calling the hook set single-hook. - Add the missing KimiFiles regression in both engines: a mocked files client rejecting with a Moonshot quota 429 makes uploadVideo reject with the non-retryable APIProviderQuotaExhaustedError, locking the classifyKimiQuotaError argument at the upload catch sites.
|
@sailist — hardened the hook wiring per further review, in three follow-ups (db499a0, 79b840b, 2aa7918), PTAL when you have a chance:
kosong / agent-core-v2 suites green; PR description is up to date. |
commit: |
|
Thanks a lot for your work on this, @vinlee19! 🙏 |
Related Issue
Part of #1860 — this PR fixes the engine-side fail-fast half. Companion UI PR: #1861 (live retry progress in the TUI), which closes the issue together with this one.
Problem
When an account's quota/balance is exhausted, Moonshot returns HTTP 429 with a structured body:
normalizeAPIStatusErrorclassifies every 429 as a retryableAPIProviderRateLimitError— the structurederror.type/error.codethat the OpenAI SDK parses onto itsAPIErroris dropped at theconvertOpenAIErrorseam. A quota-exhausted 429 can never succeed on retry, yet it burns the full retry budget: 10 attempts with 0.5→32s exponential backoff, ~3 minutes per turn, with no feedback in the interactive TUI. Users read it as a frozen app.Captured session log (kimi-code 0.26.0, provider
type = "kimi"againstapi.moonshot.cn, one turn ≈ 189s):The same account produced two different message wordings for the same
error.type("You exceeded your current token quota: … please check your account balance" earlier, the "suspended due to insufficient balance" variant later) — so message text alone is not a reliable discriminator, but the structured type is.What changed
Layering (reworked per review — the error type is generic, the classification is vendor-owned):
APIProviderQuotaExhaustedError(extendsAPIStatusError; deliberately not a subclass ofAPIProviderRateLimitError), excluded fromisRetryableGenerateError(fails fast even when aretry-afterheader is present) and fromisProviderRateLimitError(no swarm requeue/suspend). This type and those consumers are the only quota knowledge left in the contract layer —normalizeAPIStatusErrorstays vendor-free.convertErrorvendor hook:ProtocolTrait.convertErrorin agent-core-v2 (single-value, last-declarer-wins; bound bycomposeOpenAIChatHooks/composeAnthropicHooks/traitConvertError), and an equivalent optional hook parameter onconvertOpenAIError/convertAnthropicErrorin kosong. Bases consult it — right after the abort guard — with the raw failure: the SDK error on HTTP paths, the raw event on the Responses in-stream error path (the streamed message threads the hook through).kimi-errors.ts(classifyKimiQuotaError): the structuredexceeded_current_quota_errortype/code plus the observed billing wordings as a fallback for gateways that flatten the body — deliberately no bare/quota/or/balance/patterns, so transient "token quota per minute" throttles keep retrying. Declared onkimiOpenAITraitandkimiAnthropicTrait(v2), and at theKimiChatProvider/KimiFilescatch sites (kosong, which has no trait seam).insufficient_quotacode (HTTP and Responses stream events) as protocol knowledge of that wire.toKimiErrorPayload(v1) andtranslateProviderError(v2) map the type to the existingprovider.api_errorcode (retryable: false) instead ofprovider.rate_limit(retryable: true) — no new protocol code needed; the provider's actionable "please recharge" message is preserved on the payload.classifyApiErrorreportsquota_exhaustedin telemetry.loop_controlsection (en/zh) now states retries only apply to transient failures.Behavior notes for reviewers:
type = "kimi"(andopenaiwith OpenAI's own quota code) fails fast exactly as before; quota-failed swarm subagents fail instead of suspending indefinitely as "Rate limited...".provider.api_error(retryable: false) rather thanprovider.rate_limit.openaitransport is no longer classified by the shared base (vendor-neutral by design, per review) — it keeps the standard 429 retry ladder, and tests assert that.rate_limit_reached_errorbody withretry-after: 1still walks the full 10-attempt ladder honoring the header); the relocated classification is covered by unit/integration tests at every consult seam — trait declaration, hook composition, HTTP catch, Responses in-stream event, and the vendor-neutral base.Checklist
gen-changesetsskill, or this PR needs no changeset. (changeset added: patch for@moonshot-ai/kimi-codeand@moonshot-ai/kimi-code-sdk)gen-docsskill, or this PR needs no doc update. (en/zhconfig-files.mdupdated)