Skip to content

Commit 439dcbd

Browse files
fix: clarify provider quota errors (#91390)
Summary: - The branch adds provider error classification for generic HTTP 429 runtime failures and Volcengine `InvalidSubscription` billing errors, plus focused regression tests and SIGTERM test stabilization. - PR surface: Source +62, Tests +137. Total +199 across 8 files. - Reproducibility: yes. at source level. Current main lacks the HTTP 429 metadata classifier and Volcengine subscription billing matcher, and the PR body reports a live Volcengine failure shape plus after-fix tests. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: clarify provider quota errors Validation: - ClawSweeper review passed for head 5e10848. - Required merge gates passed before the squash merge. Prepared head SHA: 5e10848 Review: #91390 (comment) Co-authored-by: Mason Huang <masonxhuang@tencent.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
1 parent 310d28f commit 439dcbd

8 files changed

Lines changed: 229 additions & 30 deletions

src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,16 @@ describe("formatAssistantErrorText", () => {
245245
});
246246
expect(result).toBe(formatBillingErrorMessage("openrouter", "openai/gpt-5.5"));
247247
});
248+
it("returns billing guidance for Volcengine Coding Plan subscription failures", () => {
249+
const msg = makeAssistantError(
250+
'HTTP 400 Bad Request: {"error":{"code":"InvalidSubscription","message":"Your account does not have a valid CodingPlan subscription, or your subscription has expired."}}',
251+
);
252+
const result = formatAssistantErrorText(msg, {
253+
provider: "volcengine-plan",
254+
model: "ark-code-latest",
255+
});
256+
expect(result).toBe(formatBillingErrorMessage("volcengine-plan", "ark-code-latest"));
257+
});
248258
it("returns a friendly message for rate limit errors", () => {
249259
const msg = makeAssistantError("429 rate limit reached");
250260
expect(formatAssistantErrorText(msg)).toContain("rate limit reached");

src/agents/embedded-agent-helpers/failover-matches.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Covers provider-specific failover matcher regressions.
22
import { describe, expect, it } from "vitest";
3+
import { classifyFailoverReason } from "./errors.js";
34
import {
45
isAuthErrorMessage,
56
isBillingErrorMessage,
@@ -102,6 +103,34 @@ describe("Z.ai vendor error codes (#48988)", () => {
102103
});
103104
});
104105

106+
describe("Volcengine Coding Plan subscription errors", () => {
107+
it("classifies InvalidSubscription JSON body as billing", () => {
108+
const raw =
109+
'{"error":{"code":"InvalidSubscription","message":"Your account does not have a valid CodingPlan subscription, or your subscription has expired."}}';
110+
expect(isBillingErrorMessage(raw)).toBe(true);
111+
});
112+
113+
it("classifies long InvalidSubscription payloads as billing", () => {
114+
const raw = JSON.stringify({
115+
error: {
116+
code: "InvalidSubscription",
117+
message:
118+
"Your account does not have a valid coding plan subscription, or your subscription has expired.",
119+
details: "x".repeat(700),
120+
},
121+
});
122+
expect(raw.length).toBeGreaterThan(512);
123+
expect(isBillingErrorMessage(raw)).toBe(true);
124+
});
125+
126+
it("classifies InvalidSubscription as billing before auth or rate limit", () => {
127+
const raw =
128+
'{"error":{"code":"InvalidSubscription","message":"Your account does not have a valid CodingPlan subscription, or your subscription has expired."}}';
129+
expect(isRateLimitErrorMessage(raw)).toBe(false);
130+
expect(classifyFailoverReason(raw)).toBe("billing");
131+
});
132+
});
133+
105134
describe("server error status classification", () => {
106135
it("classifies a bare internal server error status as server error", () => {
107136
// Bare status lines from providers should classify, while prefixed prose is

src/agents/embedded-agent-helpers/failover-matches.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const CJK_AUTH_ERROR_PATTERNS = [
5656

5757
const ZAI_BILLING_CODE_1311_RE = /"code"\s*:\s*1311\b/;
5858
const ZAI_AUTH_CODE_1113_RE = /"code"\s*:\s*1113\b/;
59+
const VOLCENGINE_INVALID_SUBSCRIPTION_RE = /"code"\s*:\s*"InvalidSubscription"/i;
5960
const STATUS_INTERNAL_SERVER_ERROR_RE = /\bstatus:\s*internal server error\b/i;
6061
const STATUS_INTERNAL_SERVER_ERROR_WITH_500_RE =
6162
/^(?=[\s\S]*\bstatus:\s*internal server error\b)(?=[\s\S]*\bcode["']?\s*[:=]\s*500\b)/i;
@@ -207,6 +208,10 @@ const ERROR_PATTERNS = {
207208
"账户余额不足",
208209
"欠费",
209210
"账户已欠费",
211+
// Volcengine Coding Plan entitlement failure. Official Ark error code:
212+
// HTTP 400 + InvalidSubscription means the plan is missing or expired.
213+
VOLCENGINE_INVALID_SUBSCRIPTION_RE,
214+
/\bdoes not have a valid coding\s*plan subscription\b/i,
210215
// Z.ai: error 1311 = model not included in current subscription plan (#48988)
211216
ZAI_BILLING_CODE_1311_RE,
212217
/\bcurrent\s+subscription\s+plan\b.*\b(?:does\s+not|doesn't|not)\b.*\binclude\s+access\b/i,
@@ -281,7 +286,11 @@ export function isBillingErrorMessage(raw: string): boolean {
281286
}
282287

283288
if (raw.length > BILLING_ERROR_MAX_LENGTH) {
284-
return BILLING_ERROR_HARD_402_RE.test(value) || ZAI_BILLING_CODE_1311_RE.test(value);
289+
return (
290+
BILLING_ERROR_HARD_402_RE.test(value) ||
291+
ZAI_BILLING_CODE_1311_RE.test(value) ||
292+
VOLCENGINE_INVALID_SUBSCRIPTION_RE.test(value)
293+
);
285294
}
286295
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) {
287296
return true;

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
2424
} from "./agent-runner-execution.js";
2525
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
26-
import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./provider-request-error-classifier.js";
26+
import {
27+
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
28+
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
29+
} from "./provider-request-error-classifier.js";
2730
import type { FollowupRun } from "./queue.js";
2831
import type { ReplyOperation } from "./reply-run-registry.js";
2932
import type { TypingSignaler } from "./typing-mode.js";
@@ -98,31 +101,36 @@ vi.mock("../../agents/bootstrap-budget.js", () => ({
98101
resolveBootstrapWarningSignaturesSeen: () => [],
99102
}));
100103

101-
vi.mock("../../agents/embedded-agent-helpers.js", () => ({
102-
BILLING_ERROR_USER_MESSAGE: "billing",
103-
formatRateLimitOrOverloadedErrorCopy: (message: string) => {
104-
if (/model\s+(?:is\s+)?at capacity/i.test(message)) {
105-
return "⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
106-
}
107-
if (/rate.limit|too many requests|429/i.test(message)) {
108-
return "⚠️ API rate limit reached. Please try again later.";
109-
}
110-
if (/overloaded/i.test(message)) {
111-
return "The AI service is temporarily overloaded. Please try again in a moment.";
112-
}
113-
return undefined;
114-
},
115-
isCompactionFailureError: (message?: string) => state.isCompactionFailureErrorMock(message),
116-
isContextOverflowError: (message?: string) => state.isContextOverflowErrorMock(message),
117-
isBillingErrorMessage: () => false,
118-
isLikelyContextOverflowError: (message?: string) =>
119-
state.isLikelyContextOverflowErrorMock(message),
120-
isOverloadedErrorMessage: (message: string) => /overloaded|capacity/i.test(message),
121-
isRateLimitErrorMessage: (message: string) =>
122-
/rate.limit|too many requests|429|usage limit/i.test(message),
123-
isTransientHttpError: () => false,
124-
sanitizeUserFacingText: (text?: string) => text ?? "",
125-
}));
104+
vi.mock("../../agents/embedded-agent-helpers.js", async () => {
105+
const actual = await vi.importActual<typeof import("../../agents/embedded-agent-helpers.js")>(
106+
"../../agents/embedded-agent-helpers.js",
107+
);
108+
return {
109+
BILLING_ERROR_USER_MESSAGE: "billing",
110+
formatRateLimitOrOverloadedErrorCopy: (message: string) => {
111+
if (/model\s+(?:is\s+)?at capacity/i.test(message)) {
112+
return "⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
113+
}
114+
if (/rate.limit|too many requests|429/i.test(message)) {
115+
return "⚠️ API rate limit reached. Please try again later.";
116+
}
117+
if (/overloaded/i.test(message)) {
118+
return "The AI service is temporarily overloaded. Please try again in a moment.";
119+
}
120+
return undefined;
121+
},
122+
isCompactionFailureError: (message?: string) => state.isCompactionFailureErrorMock(message),
123+
isContextOverflowError: (message?: string) => state.isContextOverflowErrorMock(message),
124+
isBillingErrorMessage: actual.isBillingErrorMessage,
125+
isLikelyContextOverflowError: (message?: string) =>
126+
state.isLikelyContextOverflowErrorMock(message),
127+
isOverloadedErrorMessage: (message: string) => /overloaded|capacity/i.test(message),
128+
isRateLimitErrorMessage: (message: string) =>
129+
/rate.limit|too many requests|429|usage limit/i.test(message),
130+
isTransientHttpError: () => false,
131+
sanitizeUserFacingText: (text?: string) => text ?? "",
132+
};
133+
});
126134

127135
vi.mock("../../config/sessions.js", () => ({
128136
resolveGroupSessionKey: vi.fn(() => null),
@@ -5420,6 +5428,58 @@ describe("runAgentTurnWithFallback", () => {
54205428
}
54215429
});
54225430

5431+
it("surfaces provider quota guidance for generic HTTP 429 failures before reply", async () => {
5432+
const error = new Error(
5433+
"Something went wrong while processing your request. Please try again.",
5434+
);
5435+
Object.assign(error, { status: 429 });
5436+
state.runEmbeddedAgentMock.mockRejectedValueOnce(error);
5437+
5438+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
5439+
const result = await runAgentTurnWithFallback(
5440+
createMinimalRunAgentTurnParams({
5441+
sessionCtx: {
5442+
Provider: "discord",
5443+
Surface: "discord",
5444+
ChatType: "direct",
5445+
MessageSid: "msg",
5446+
} as unknown as TemplateContext,
5447+
}),
5448+
);
5449+
5450+
expect(result.kind).toBe("final");
5451+
if (result.kind === "final") {
5452+
expect(result.payload.text).toBe(PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE);
5453+
expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT);
5454+
}
5455+
});
5456+
5457+
it("surfaces billing guidance for Volcengine Coding Plan subscription failures before reply", async () => {
5458+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
5459+
new Error(
5460+
'HTTP 400 Bad Request: {"error":{"code":"InvalidSubscription","message":"Your account does not have a valid CodingPlan subscription, or your subscription has expired."}}',
5461+
),
5462+
);
5463+
5464+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
5465+
const result = await runAgentTurnWithFallback(
5466+
createMinimalRunAgentTurnParams({
5467+
sessionCtx: {
5468+
Provider: "discord",
5469+
Surface: "discord",
5470+
ChatType: "direct",
5471+
MessageSid: "msg",
5472+
} as unknown as TemplateContext,
5473+
}),
5474+
);
5475+
5476+
expect(result.kind).toBe("final");
5477+
if (result.kind === "final") {
5478+
expect(result.payload.text).toBe("billing");
5479+
expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT);
5480+
}
5481+
});
5482+
54235483
it("formats raw Codex API payloads before forwarding verbose external errors", async () => {
54245484
state.runEmbeddedAgentMock.mockRejectedValueOnce(
54255485
new Error(

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ function buildExternalRunFailureReply(
801801
if (authProfileFailoverFailure) {
802802
return { text: authProfileFailoverFailure, isGenericRunnerFailure: false };
803803
}
804-
const providerRequestError = classifyProviderRequestError(normalizedMessage);
804+
const providerRequestError = classifyProviderRequestError(error ?? normalizedMessage);
805805
if (providerRequestError) {
806806
return {
807807
text: providerRequestError.userMessage,

src/auto-reply/reply/provider-request-error-classifier.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import {
44
classifyProviderRequestError,
55
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
6+
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
67
} from "./provider-request-error-classifier.js";
78

89
describe("provider request error classifier", () => {
@@ -32,7 +33,30 @@ describe("provider request error classifier", () => {
3233
});
3334
});
3435

35-
it("ignores unrelated provider errors", () => {
36+
it("leaves explicit HTTP 429 rate-limit failures on the existing rate-limit path", () => {
3637
expect(classifyProviderRequestError(new Error("429: rate limit exceeded"))).toBeUndefined();
3738
});
39+
40+
it.each([
41+
["top-level status", { status: 429 }],
42+
["response status", { response: { status: "429" } }],
43+
["cause statusCode", { cause: { statusCode: 429 } }],
44+
])("classifies generic HTTP 429 errors from %s metadata", (_label, metadata) => {
45+
const error = new Error(
46+
"Something went wrong while processing your request. Please try again.",
47+
);
48+
Object.assign(error, metadata);
49+
50+
expect(classifyProviderRequestError(error)).toEqual({
51+
code: "provider_rate_limit_or_quota_error",
52+
userMessage: PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
53+
technicalMessage: "Something went wrong while processing your request. Please try again.",
54+
});
55+
});
56+
57+
it("ignores unrelated provider errors", () => {
58+
expect(
59+
classifyProviderRequestError(new Error("INVALID_ARGUMENT: some other failure")),
60+
).toBeUndefined();
61+
});
3862
});

src/auto-reply/reply/provider-request-error-classifier.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
33
import { formatErrorMessage } from "../../infra/errors.js";
44

55
/** Provider request error classes that get a specialized user-facing reply. */
6-
export type ProviderRequestErrorCode = "provider_conversation_state_error";
6+
export type ProviderRequestErrorCode =
7+
| "provider_conversation_state_error"
8+
| "provider_rate_limit_or_quota_error";
79

810
/** Structured provider error classification for reply failure handling. */
911
export type ProviderRequestErrorClassification = {
@@ -16,11 +18,24 @@ export type ProviderRequestErrorClassification = {
1618
export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE =
1719
"⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session.";
1820

21+
export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE =
22+
"⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again.";
23+
1924
/** Classifies provider request failures that are actionable for users. */
2025
export function classifyProviderRequestError(
2126
err: unknown,
2227
): ProviderRequestErrorClassification | undefined {
2328
const technicalMessage = formatErrorMessage(err);
29+
if (
30+
hasHttp429Evidence(err, technicalMessage) &&
31+
isGenericProviderRuntimeErrorMessage(technicalMessage)
32+
) {
33+
return {
34+
code: "provider_rate_limit_or_quota_error",
35+
userMessage: PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
36+
technicalMessage,
37+
};
38+
}
2439
if (isProviderConversationStateErrorMessage(technicalMessage)) {
2540
return {
2641
code: "provider_conversation_state_error",
@@ -45,3 +60,41 @@ export function isProviderConversationStateErrorMessage(message: string): boolea
4560
lower.includes("roles must alternate")
4661
);
4762
}
63+
64+
function isGenericProviderRuntimeErrorMessage(message: string): boolean {
65+
const lower = normalizeLowercaseStringOrEmpty(message);
66+
return (
67+
lower.includes("an error occurred while processing your request") ||
68+
lower.includes("something went wrong while processing your request")
69+
);
70+
}
71+
72+
function hasHttp429Evidence(err: unknown, message: string): boolean {
73+
return (
74+
readHttp429Status(err) ||
75+
/\b(?:http\s*)?429\b|["'](?:status|code)["']\s*:\s*429\b/iu.test(message)
76+
);
77+
}
78+
79+
function readHttp429Status(err: unknown, seen = new Set<unknown>()): boolean {
80+
if (!err || typeof err !== "object" || seen.has(err)) {
81+
return false;
82+
}
83+
seen.add(err);
84+
const candidate =
85+
(err as { status?: unknown; statusCode?: unknown }).status ??
86+
(err as { statusCode?: unknown }).statusCode;
87+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
88+
if (candidate === 429) {
89+
return true;
90+
}
91+
} else if (typeof candidate === "string" && Number(candidate.trim()) === 429) {
92+
return true;
93+
}
94+
const nested = err as { cause?: unknown; error?: unknown; response?: unknown };
95+
return (
96+
readHttp429Status(nested.response, seen) ||
97+
readHttp429Status(nested.error, seen) ||
98+
readHttp429Status(nested.cause, seen)
99+
);
100+
}

0 commit comments

Comments
 (0)