Skip to content

fix(kosong): fail fast on quota-exhausted 429 instead of retrying - #1857

Merged
sailist merged 8 commits into
MoonshotAI:mainfrom
vinlee19:fix/quota-429-fail-fast
Jul 28, 2026
Merged

fix(kosong): fail fast on quota-exhausted 429 instead of retrying#1857
sailist merged 8 commits into
MoonshotAI:mainfrom
vinlee19:fix/quota-429-fail-fast

Conversation

@vinlee19

@vinlee19 vinlee19 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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:

HTTP/2 429            (no retry-after header)
{"error":{"message":"Your account org-… <ak-…> is suspended due to insufficient balance,
  please recharge your account or check your plan and billing details",
  "type":"exceeded_current_quota_error"}}

normalizeAPIStatusError classifies every 429 as a retryable APIProviderRateLimitError — the structured error.type/error.code that the OpenAI SDK parses onto its APIError is dropped at the convertOpenAIError seam. 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" against api.moonshot.cn, one turn ≈ 189s):

11:51:30.355 INFO  llm request  turnStep=15.1
11:51:35.358 INFO  llm request  turnStep=15.1 attempt=2/10
...
11:54:34.426 INFO  llm request  turnStep=15.1 attempt=10/10
11:54:39.021 WARN  llm request failed  ... errorName=APIProviderRateLimitError statusCode=429
11:54:39.037 ERROR turn failed  turnId=15

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):

  • New APIProviderQuotaExhaustedError (extends APIStatusError; deliberately not a subclass of APIProviderRateLimitError), excluded from isRetryableGenerateError (fails fast even when a retry-after header is present) and from isProviderRateLimitError (no swarm requeue/suspend). This type and those consumers are the only quota knowledge left in the contract layer — normalizeAPIStatusError stays vendor-free.
  • New convertError vendor 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 in 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).
  • Moonshot's quota signals live Kimi-side in kimi-errors.ts (classifyKimiQuotaError): the structured exceeded_current_quota_error type/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 on kimiOpenAITrait and kimiAnthropicTrait (v2), and at the KimiChatProvider / KimiFiles catch sites (kosong, which has no trait seam).
  • The OpenAI bases recognize only OpenAI's own documented insufficient_quota code (HTTP and Responses stream events) as protocol knowledge of that wire.
  • toKimiErrorPayload (v1) and translateProviderError (v2) map the type to the existing provider.api_error code (retryable: false) instead of provider.rate_limit (retryable: true) — no new protocol code needed; the provider's actionable "please recharge" message is preserved on the payload. classifyApiError reports quota_exhausted in telemetry.
  • Docs: the loop_control section (en/zh) now states retries only apply to transient failures.

Behavior notes for reviewers:

  • Provider type = "kimi" (and openai with OpenAI's own quota code) fails fast exactly as before; quota-failed swarm subagents fail instead of suspending indefinitely as "Rate limited...".
  • Quota errors cross the wire as provider.api_error (retryable: false) rather than provider.rate_limit.
  • An unregistered vendor speaking Moonshot-style billing wordings through a plain openai transport 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.
  • Transient rate-limit 429 behavior is unchanged. The captured quota body was verified end-to-end against a mock provider on the original classification seam (fails at attempt 1/10 with the provider's "please recharge" message; a rate_limit_reached_error body with retry-after: 1 still 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

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset. (changeset added: patch for @moonshot-ai/kimi-code and @moonshot-ai/kimi-code-sdk)
  • Ran gen-docs skill, or this PR needs no doc update. (en/zh config-files.md updated)

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-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2aa7918

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@moonshot-ai/kimi-code Patch
@moonshot-ai/kimi-code-sdk Patch

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/agent-core-v2/src/app/llmProtocol/errors.ts Outdated
Comment thread packages/kosong/src/providers/openai-common.ts Outdated
vinlee19 added 2 commits July 17, 2026 23:28
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.
@vinlee19

Copy link
Copy Markdown
Contributor Author

Pushed two follow-up commits after a local review pass:

  • 5c60c91 — the openai_responses provider path was still burning the retry budget on quota exhaustion: Responses response.failed/error SSE events carry no HTTP status and were minted as a base ChatProviderError (whose unclassified fallback is retryable) or, with an embedded status_code=429, as a retryable rate limit. errorFromOpenAIResponsesEvent now routes the event code/message through the same quota-exhausted check before the rate-limit branch (kosong + agent-core-v2 mirror; all three entry paths share that converter). Tests added for both engines.
  • 7ceabcc — dropped the inline comments my first commit added in agent-core-v2 sources, per its AGENTS.md header-only comment convention; the kosong twins keep the full rationale.

@vinlee19

Copy link
Copy Markdown
Contributor Author

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
stuck process and dumped JS stacks — the event loop was idle apart from the render tick, which led me to the silent 429 retry ladder. Analysis + captured 429 body:
#1860

Fixes: #1857 (fail fast on quota-exhausted 429) and #1861 (show retry progress in
the TUI). Feedback welcome!

…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
@vinlee19

Copy link
Copy Markdown
Contributor Author

Hi @RealKai42 @sailist — merged latest main and resolved the conflicts from the #1970 restructure: the v2 changes now live under src/kosong/ (contract/errors.ts, provider/bases/openai/*, protocol/errors.ts), and the quota tests from the deleted provider-errors.test.ts moved into test/kosong/provider/errors.test.ts. kosong and agent-core-v2 suites are green locally, and the agent-core tests covering this change pass as well. PTAL when you have a chance 🙏

@sailist

sailist commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Suggestion: move the quota-exhausted classification into the kimi trait via a convertError hook

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 exceeded_current_quota_error type, the observed billing message wordings — is vendor-specific: it describes how the Kimi backend signals quota exhaustion. Right now that knowledge lands in the shared OpenAI base / contract layer, which means it runs for every OpenAI-compatible provider (custom base_url endpoints, unregistered vendors, gateways in front of other backends). A 429 carrying these signals means "quota exhausted, fail fast" on Kimi — the shared base shouldn't be the place that decides what a vendor's 429 means.

The split I'd propose follows the existing layering: the error type is generic, the error conversion is not.

  • APIProviderQuotaExhaustedError itself, plus its consumers (isRetryableGenerateError, isProviderRateLimitError, the provider.api_error wire mapping) stay in the contract layer exactly as this PR has them — they're vendor-neutral and consumed via instanceof. That part is placed correctly.
  • The classification — which raw backend error mints that type — is a vendor deviation, and vendor deviations are what traits are for. Concretely:
    1. Add a convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined hook to ProtocolTrait (single-value, last-declarer-wins; undefined = keep the base classification). It must receive the raw SDK error, since the base conversion drops the parsed error.code / error.type.
    2. Bind it in composeOpenAIChatHooks and consult it at the catch sites in openai-legacy / openai-responses (including the Responses in-stream error-event path) before falling back to convertOpenAIError.
    3. Declare the Moonshot codes + billing message patterns on kimiOpenAITrait via that hook.
    4. The new options parameter on normalizeAPIStatusError can then be dropped — the base stays free of vendor knowledge.

Two places need explicit wiring so nothing regresses: the (kimi, anthropic) registration (the Anthropic base calls normalizeAPIStatusError directly, so it needs the same hook point) and kimi-files.ts, which calls convertOpenAIError without a hooks context today.

This keeps openai-common vendor-neutral and gives every other vendor the same extension point when they need to express their own error semantics.

vinlee19 added 4 commits July 27, 2026 21:45
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.
@vinlee19

vinlee19 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@sailist — hardened the hook wiring per further review, in three follow-ups (db499a0, 79b840b, 2aa7918), PTAL when you have a chance:

  • legacy Kimi-over-Anthropic now classifies quota too: AnthropicOptions.convertError threaded through the provider and stream wrapper, ProviderManager passes classifyKimiQuotaError on that route, and the classifier reads the Anthropic SDK's nested body shape;
  • v2 telemetry reports quota_exhausted instead of rate_limit, matching the legacy engine;
  • already-converted errors pass through before the hook, so it sees each raw failure exactly once (asserted in tests);
  • regression tests now cover the real assembly paths: registry-composed (kimi, anthropic), ProviderManager routing, and KimiFiles uploads — each fails fast on a Moonshot quota 429 while a plain anthropic composition keeps the same 429 retryable.

kosong / agent-core-v2 suites green; PR description is up to date.

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@2aa7918
npx https://pkg.pr.new/@moonshot-ai/kimi-code@2aa7918

commit: 2aa7918

@sailist

sailist commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Thanks a lot for your work on this, @vinlee19! 🙏

@sailist
sailist merged commit cdbd33c into MoonshotAI:main Jul 28, 2026
14 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants