Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quota-exhausted-fail-fast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---

Fail fast on quota/balance-exhausted HTTP 429 errors (e.g. Moonshot `exceeded_current_quota_error`, OpenAI `insufficient_quota`) instead of silently retrying for ~3 minutes. Transient rate-limit 429s keep the existing retry, backoff, and Retry-After behavior.
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ You can also switch models temporarily without touching the config file — by s
| `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure |
| `reserved_context_size` | `integer` | — | Number of tokens reserved for model output; automatic compaction is triggered when the remaining context window falls below this value |

Retries only apply to transient failures — connection errors, timeouts, HTTP 429 rate limits, and 5xx server errors. A 429 caused by an exhausted quota or insufficient account balance is not retried and fails immediately, since it cannot succeed until the account is recharged.

## `background`

`background` controls the concurrency behavior of background tasks (launched via the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter).
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ display_name = "Kimi for Coding (custom)"
| `max_retries_per_step` | `integer` | `10` | 单步失败后的最大重试次数 |
| `reserved_context_size` | `integer` || 预留给模型输出的 token 数;上下文窗口剩余量低于此值时触发自动压缩 |

重试仅针对瞬时故障——连接错误、超时、HTTP 429 限流和 5xx 服务端错误。账户额度耗尽或余额不足导致的 429 不会重试,会立即失败:在充值之前重试不可能成功。

## `background`

`background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数。
Expand Down
45 changes: 45 additions & 0 deletions packages/agent-core-v2/src/app/llmProtocol/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ export class APIProviderRateLimitError extends APIStatusError {
}
}

export class APIProviderQuotaExhaustedError extends APIStatusError {
constructor(
message: string,
requestId?: string | null,
retryAfterMs?: number | null,
traceId?: string | null,
) {
super(429, message, requestId, retryAfterMs, traceId);
this.name = 'APIProviderQuotaExhaustedError';
}
}

export class APIProviderOverloadedError extends APIStatusError {
constructor(
statusCode: number,
Expand Down Expand Up @@ -158,6 +170,9 @@ export function isRetryableGenerateError(error: unknown): boolean {
return true;
}
if (error instanceof APIStatusError) {
if (error instanceof APIProviderQuotaExhaustedError) {
return false;
}
return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode);
}
return error instanceof ChatProviderError && !isImageFormatError(error);
Expand Down Expand Up @@ -199,6 +214,17 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [

const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const;

const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']);

const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [
/exceeded your current (?:token )?quota/,
/check your account balance/,
/insufficient balance/,
/recharge your account|please recharge/,
/account (?:is )?in arrears/,
/insufficient_quota/,
] as const;

const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [
/request exceeds the maximum size/,
/request entity too large/,
Expand Down Expand Up @@ -242,8 +268,12 @@ export function normalizeAPIStatusError(
requestId?: string | null,
retryAfterMs?: number | null,
traceId?: string | null,
options?: { readonly errorCode?: string | null; readonly errorType?: string | null },
): APIStatusError {
if (statusCode === 429) {
if (isQuotaExhaustedStatusError(statusCode, message, options)) {
return new APIProviderQuotaExhaustedError(message, requestId, retryAfterMs, traceId);
}
return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId);
}
if (isContextOverflowStatusError(statusCode, message)) {
Expand Down Expand Up @@ -307,6 +337,20 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string
return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}

export function isQuotaExhaustedStatusError(
statusCode: number,
message: string,
options?: { readonly errorCode?: string | null; readonly errorType?: string | null },
): boolean {
if (statusCode !== 429) return false;
const errorCode = options?.errorCode;
if (typeof errorCode === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorCode)) return true;
const errorType = options?.errorType;
if (typeof errorType === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorType)) return true;
const lowerMessage = message.toLowerCase();
return QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}

const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [
/tool_use[\s\S]*tool_result/,
/tool_result[\s\S]*tool_use/,
Expand Down Expand Up @@ -345,6 +389,7 @@ export function isRecoverableRequestStructureError(error: unknown): boolean {
}

export function isProviderRateLimitError(error: unknown): boolean {
if (error instanceof APIProviderQuotaExhaustedError) return false;
if (error instanceof APIProviderRateLimitError) return true;

const statusCode = getStatusCode(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export function convertOpenAIError(error: unknown): ChatProviderError {
reqId,
parseRetryAfterMs(error.headers),
parseTraceId(error.headers),
{
errorCode: typeof error.code === 'string' ? error.code : null,
errorType: typeof error.type === 'string' ? error.type : null,
},
);
}
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
APIContextOverflowError,
APIProviderQuotaExhaustedError,
APIProviderRateLimitError,
ChatProviderError,
isContextOverflowErrorCode,
isQuotaExhaustedStatusError,
} from '../errors';
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message';
import { extractText, isToolDeclarationOnlyMessage } from '../message';
Expand Down Expand Up @@ -242,6 +244,9 @@ function errorFromOpenAIResponsesEvent(
if (isContextOverflowErrorCode(code)) {
return new APIContextOverflowError(400, fullMessage);
}
if (isQuotaExhaustedStatusError(429, fullMessage, { errorCode: code })) {
return new APIProviderQuotaExhaustedError(fullMessage);
}
if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) {
return new APIProviderRateLimitError(fullMessage);
}
Expand Down
13 changes: 8 additions & 5 deletions packages/agent-core-v2/src/app/protocol/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderOverloadedError,
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
ChatProviderError,
Expand Down Expand Up @@ -84,11 +85,13 @@ export function translateProviderError(error: unknown): Error2 {
? ProtocolErrors.codes.CONTEXT_OVERFLOW
: error instanceof APIProviderOverloadedError || error.statusCode === 529
? ProtocolErrors.codes.PROVIDER_OVERLOADED
: error.statusCode === 429
? ProtocolErrors.codes.PROVIDER_RATE_LIMIT
: error.statusCode === 401 || error.statusCode === 403
? ProtocolErrors.codes.PROVIDER_AUTH_ERROR
: ProtocolErrors.codes.PROVIDER_API_ERROR;
: error instanceof APIProviderQuotaExhaustedError
? ProtocolErrors.codes.PROVIDER_API_ERROR
: error.statusCode === 429
? ProtocolErrors.codes.PROVIDER_RATE_LIMIT
: error.statusCode === 401 || error.statusCode === 403
? ProtocolErrors.codes.PROVIDER_AUTH_ERROR
: ProtocolErrors.codes.PROVIDER_API_ERROR;
return new Error2(code, sanitizeStatusErrorMessage(error.message), {
name: error.name,
cause: error,
Expand Down
54 changes: 54 additions & 0 deletions packages/agent-core-v2/test/app/llmProtocol/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderOverloadedError,
APIProviderQuotaExhaustedError,
APIProviderRateLimitError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
ChatProviderError,
isImageFormatError,
isProviderRateLimitError,
isQuotaExhaustedStatusError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
isToolExchangeAdjacencyError,
Expand Down Expand Up @@ -696,3 +698,55 @@ describe('isProviderRateLimitError', () => {
expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false);
});
});

describe('quota-exhausted 429 classification', () => {
it.each([
'You exceeded your current token quota: <org-0123456789abcdef> 31275, please check your account balance',
'Your account org-0123456789abcdef <ak-test> is suspended due to insufficient balance, please recharge your account or check your plan and billing details',
'You exceeded your current quota, please check your plan and billing details.',
'Your account is in arrears, please top up',
])('normalizes 429 "%s" to APIProviderQuotaExhaustedError by message', (message) => {
const error = normalizeAPIStatusError(429, message, 'req-quota');
expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(error).not.toBeInstanceOf(APIProviderRateLimitError);
expect(error.statusCode).toBe(429);
});

it('classifies a neutral message by structured errorType/errorCode', () => {
expect(
normalizeAPIStatusError(429, 'Too many requests', null, null, null, {
errorType: 'exceeded_current_quota_error',
}),
).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(
normalizeAPIStatusError(429, 'Too many requests', null, null, null, {
errorCode: 'insufficient_quota',
}),
).toBeInstanceOf(APIProviderQuotaExhaustedError);
});

it.each([
'Too many requests',
'request reached user+model max RPM: 50',
'your token quota per minute was exceeded',
])('keeps transient 429 "%s" an APIProviderRateLimitError', (message) => {
const error = normalizeAPIStatusError(429, message);
expect(error).toBeInstanceOf(APIProviderRateLimitError);
expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError);
});

it('is gated on status 429', () => {
expect(isQuotaExhaustedStatusError(403, 'insufficient balance')).toBe(false);
expect(normalizeAPIStatusError(403, 'insufficient balance')).not.toBeInstanceOf(
APIProviderQuotaExhaustedError,
);
});

it('is neither retryable nor a provider rate limit', () => {
const quota = new APIProviderQuotaExhaustedError('quota exhausted', 'req-quota', 1);
expect(isRetryableGenerateError(quota)).toBe(false);
expect(isProviderRateLimitError(quota)).toBe(false);
expect(isRetryableGenerateError(new APIProviderRateLimitError('rate limited'))).toBe(true);
expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { APIError as AnthropicAPIError } from '@anthropic-ai/sdk';
import { APIError as OpenAIAPIError } from 'openai';
import { describe, expect, it } from 'vitest';

import { APIProviderRateLimitError, APIStatusError } from '#/app/llmProtocol/errors';
import {
APIProviderQuotaExhaustedError,
APIProviderRateLimitError,
APIStatusError,
isRetryableGenerateError,
} from '#/app/llmProtocol/errors';
import { convertAnthropicError } from '#/app/llmProtocol/providers/anthropic';
import { convertOpenAIError } from '#/app/llmProtocol/providers/openai-common';
import { OpenAIResponsesStreamedMessage } from '#/app/llmProtocol/providers/openai-responses';
Expand Down Expand Up @@ -91,4 +96,53 @@ describe('OpenAI Responses rate-limit conversion', () => {

await expect(consume(stream)).rejects.toBeInstanceOf(APIProviderRateLimitError);
});

it('fails fast on a streamed insufficient_quota error event', async () => {
const stream = new OpenAIResponsesStreamedMessage(
streamEvents([
{
type: 'error',
code: 'insufficient_quota',
message: 'You exceeded your current quota, please check your plan and billing details.',
param: null,
},
]),
true,
);

const caught = await consume(stream).then(
() => undefined,
(error: unknown) => error,
);
expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(isRetryableGenerateError(caught)).toBe(false);
});
});

describe('OpenAI quota-exhausted 429 conversion', () => {
const QUOTA_MESSAGE =
'Your account org-0123456789abcdef <ak-test> is suspended due to insufficient balance, please recharge your account or check your plan and billing details';

it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => {
const source = new OpenAIAPIError(
429,
{ message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' },
`429 ${QUOTA_MESSAGE}`,
new Headers(),
);

const error = convertOpenAIError(source);

expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(isRetryableGenerateError(error)).toBe(false);
});

it('falls back to message wording when no structured body is present', () => {
const source = new OpenAIAPIError(429, undefined, QUOTA_MESSAGE, new Headers());

const error = convertOpenAIError(source);

expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(isRetryableGenerateError(error)).toBe(false);
});
});
15 changes: 15 additions & 0 deletions packages/agent-core-v2/test/app/protocol/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderOverloadedError,
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
ChatProviderError,
Expand Down Expand Up @@ -152,4 +153,18 @@ describe('translateProviderError', () => {
);
});
});

describe('quota-exhausted 429', () => {
it('maps to provider.api_error, not provider.rate_limit', () => {
const translated = translateProviderError(
new APIProviderQuotaExhaustedError(
'Your account is suspended due to insufficient balance, please recharge your account',
'req-quota',
),
);
expect(translated.code).toBe('provider.api_error');
expect(translated.message).toContain('recharge');
expect(translated.details).toMatchObject({ statusCode: 429, requestId: 'req-quota' });
});
});
});
6 changes: 6 additions & 0 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
APIConnectionError,
APIContextOverflowError,
APIEmptyResponseError,
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
inputTotal,
Expand Down Expand Up @@ -1437,6 +1438,11 @@ interface ApiErrorClassification {
}

function classifyApiError(error: unknown, summary: KimiErrorPayload): ApiErrorClassification {
// Quota/balance exhaustion shares status 429 with rate limits but fails
// fast instead of retrying — keep the two apart in telemetry.
if (error instanceof APIProviderQuotaExhaustedError) {
return { errorType: 'quota_exhausted', statusCode: error.statusCode };
}
const statusCode = apiStatusCode(error) ?? summaryStatusCode(summary);
if (statusCode !== undefined) {
if (statusCode === 429) return { errorType: 'rate_limit', statusCode };
Expand Down
17 changes: 12 additions & 5 deletions packages/agent-core/src/errors/serialize.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
APIConnectionError,
APIEmptyResponseError,
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
ChatProviderError,
Expand Down Expand Up @@ -58,6 +59,10 @@ export function makeErrorPayload(
* Recognized errors:
* - `KimiError`: passthrough.
* - `APIStatusError`: 429 -> rate_limit, 401 -> auth_error, otherwise -> api_error.
* Exception: a quota-exhausted 429 maps to api_error (retryable: false) —
* the rate_limit code would re-mint a rate-limit error across the wire
* boundary and drive the swarm requeue/suspend loop, which cannot help
* until the account is recharged.
* - `APIConnectionError` / `APITimeoutError`: connection_error.
* - `ChatProviderError`: api_error.
*
Expand All @@ -77,11 +82,13 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload {

if (error instanceof APIStatusError) {
const code: KimiErrorCode =
error.statusCode === 429
? ErrorCodes.PROVIDER_RATE_LIMIT
: error.statusCode === 401
? ErrorCodes.PROVIDER_AUTH_ERROR
: ErrorCodes.PROVIDER_API_ERROR;
error instanceof APIProviderQuotaExhaustedError
? ErrorCodes.PROVIDER_API_ERROR
: error.statusCode === 429
? ErrorCodes.PROVIDER_RATE_LIMIT
: error.statusCode === 401
? ErrorCodes.PROVIDER_AUTH_ERROR
: ErrorCodes.PROVIDER_API_ERROR;
return {
code,
message: sanitizeStatusErrorMessage(error.message),
Expand Down
Loading