mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(llms): stop retrying Gemini daily-quota 429s and surface quota detail
Between v4.1.15 and v4.1.25 the gateway gained maxRetries=5 and automatic streamRetries=3. The AI SDK retries every 429 by status and its automatic stream retries retry every error part, so a Google per-day RESOURCE_EXHAUSTED on the free tier could be re-sent up to 24 times per turn against a quota that cannot refill that day, while the user only saw the generic quota message without quotaId or retryDelay. Parse Google's QuotaFailure/RetryInfo detail, append it to the surfaced error message, treat per-day quota exhaustion as non-retryable (per-minute limits stay retryable), convert it to a non-retryable APICallError at the Google/Vertex fetch boundary, and gate stream retries through onError so only retryable non-RetryError failures request the SDK's single retry. Fixes #15163
This commit is contained in:
+3
-2
@@ -572,8 +572,9 @@ is recorded separately as `aiSdkCallId` on errors.
|
||||
|
||||
Terminal stream failures notify both application error reporting and tracing
|
||||
integrations. Native timeout aborts are failures; explicit caller cancellation
|
||||
does not emit a provider `sdk.error`. AI SDK stream recovery permits three retries
|
||||
per model step. Recovered attempts do not produce terminal `sdk.error` reports or
|
||||
does not emit a provider `sdk.error`. AI SDK stream recovery permits one retry
|
||||
per model step, requested by `onError` only for retryable failures (never for a
|
||||
request-start `RetryError` or a daily quota exhaustion). Recovered attempts do not produce terminal `sdk.error` reports or
|
||||
mark the operation as failed. Exhausted stream recovery disables the agent's
|
||||
whole-turn retry so the two retry budgets do not multiply. Previously streamed
|
||||
text and reasoning follow AI SDK's append semantics and may repeat on recovery.
|
||||
|
||||
@@ -33,10 +33,12 @@ import {
|
||||
validateImageMedia,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
APICallError,
|
||||
type CallSettings,
|
||||
generateImage,
|
||||
jsonSchema,
|
||||
NoSuchToolError,
|
||||
RetryError,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
type ToolSet,
|
||||
@@ -50,7 +52,9 @@ import {
|
||||
} from "../services/stream-error-telemetry";
|
||||
import {
|
||||
classifyProviderError,
|
||||
isQuotaExhaustedError,
|
||||
isRetryableBeyondSdkRetries,
|
||||
isRetryableProviderError,
|
||||
} from "./error-classification";
|
||||
import { extractErrorMessage } from "./format";
|
||||
import { createRetryEmptyResponseMiddleware } from "./middleware/retry-empty-response";
|
||||
@@ -597,6 +601,55 @@ function wrapFetchForStickySession(
|
||||
return sessionFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a daily-quota 429 into a non-retryable error at the fetch boundary.
|
||||
* The AI SDK retries every 429 by status alone, so without this a Google
|
||||
* free-tier `RESOURCE_EXHAUSTED` response is re-sent six times (plus stream
|
||||
* retries) against a quota that cannot refill that day. Per-minute limits
|
||||
* are left untouched for the SDK's retry-after-aware backoff.
|
||||
*/
|
||||
function wrapFetchForQuotaExhaustion(
|
||||
baseFetch: typeof fetch | undefined,
|
||||
kind: ProviderModuleKind,
|
||||
): typeof fetch | undefined {
|
||||
if (kind !== "google" && kind !== "vertex") {
|
||||
return baseFetch;
|
||||
}
|
||||
const delegate = baseFetch ?? globalThis.fetch;
|
||||
if (!delegate) {
|
||||
return baseFetch;
|
||||
}
|
||||
return (async (input, init) => {
|
||||
const response = await delegate(input, init);
|
||||
if (response.status !== 429) {
|
||||
return response;
|
||||
}
|
||||
let bodyText: string;
|
||||
try {
|
||||
bodyText = await response.clone().text();
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
if (!isQuotaExhaustedError(bodyText)) {
|
||||
return response;
|
||||
}
|
||||
throw new APICallError({
|
||||
message: extractErrorMessage(bodyText),
|
||||
url:
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
requestBodyValues: undefined,
|
||||
statusCode: 429,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
responseBody: bodyText,
|
||||
isRetryable: false,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function shouldIncludeReasoningHistory(
|
||||
request: GatewayStreamRequest,
|
||||
context: GatewayProviderContext,
|
||||
@@ -1087,14 +1140,16 @@ function getNestedUsageValue(
|
||||
/**
|
||||
* AI SDK request-start retries (`maxRetries`) handle transient failures before
|
||||
* streaming begins, with backoff that honors `retry-after` headers. Provider
|
||||
* error parts received after streaming starts use `streamRetries` instead.
|
||||
* error parts received after streaming starts are retried only when `onError`
|
||||
* requests it (`streamRetries: 0`): the SDK's automatic stream retries retry
|
||||
* every error part, including terminal failures a retry cannot fix (auth,
|
||||
* bad request, daily quota exhaustion), multiplying quota consumption.
|
||||
*
|
||||
* Exhausted request-start retries (`RetryError`) and terminal stream error parts
|
||||
* do not trigger the agent's whole-turn retry, so the retry budgets do not
|
||||
* multiply. See `captureStreamError` and `isRetryableBeyondSdkRetries`.
|
||||
*/
|
||||
const MODEL_REQUEST_MAX_RETRIES = 5;
|
||||
const MODEL_STREAM_MAX_RETRIES = 3;
|
||||
|
||||
type UsagePath = readonly [string] | readonly [string, string];
|
||||
|
||||
@@ -1511,6 +1566,23 @@ function captureStreamError(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `onError` should ask the AI SDK to retry the current step after a
|
||||
* stream error part. Request-start failures arrive as a `RetryError` whose
|
||||
* `maxRetries` budget is already spent, and quota exhaustions or other
|
||||
* non-retryable failures cannot succeed on an immediate re-send.
|
||||
*/
|
||||
function shouldRetryStreamError(error: unknown): boolean {
|
||||
try {
|
||||
if (RetryError.isInstance(error)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the classifier when the SDK guard is unavailable.
|
||||
}
|
||||
return isRetryableProviderError(error);
|
||||
}
|
||||
|
||||
async function* emitAiSdkEvents(
|
||||
stream: AiSdkStreamResult,
|
||||
request: GatewayStreamRequest,
|
||||
@@ -2247,10 +2319,13 @@ function createAiSdkProvider(
|
||||
kind,
|
||||
{
|
||||
...config,
|
||||
fetch: wrapFetchForStickySession(
|
||||
wrapFetchForProviderRequestCapture(config.fetch, request),
|
||||
request,
|
||||
context,
|
||||
fetch: wrapFetchForQuotaExhaustion(
|
||||
wrapFetchForStickySession(
|
||||
wrapFetchForProviderRequestCapture(config.fetch, request),
|
||||
request,
|
||||
context,
|
||||
),
|
||||
kind,
|
||||
),
|
||||
},
|
||||
context,
|
||||
@@ -2472,7 +2547,7 @@ function createAiSdkProvider(
|
||||
? { stopWhen: stepCountIs(8) }
|
||||
: {}),
|
||||
...requestConfig,
|
||||
streamRetries: MODEL_STREAM_MAX_RETRIES,
|
||||
streamRetries: 0,
|
||||
...(portableReasoning ? { reasoning: portableReasoning } : {}),
|
||||
onLanguageModelCallStart: () => {
|
||||
capturedError.current = undefined;
|
||||
@@ -2481,6 +2556,9 @@ function createAiSdkProvider(
|
||||
const captured = captureStreamError(streamError);
|
||||
capturedError.current = captured;
|
||||
streamTelemetry.captureError(streamError);
|
||||
return shouldRetryStreamError(streamError)
|
||||
? { retry: true }
|
||||
: undefined;
|
||||
},
|
||||
onAbort: async ({ reason }) => {
|
||||
if (!isTimeoutReason(reason)) return;
|
||||
|
||||
@@ -449,6 +449,59 @@ describe("isRetryableProviderError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google quota failures", () => {
|
||||
const googleQuotaError = (quotaId: string) =>
|
||||
new APICallError({
|
||||
message:
|
||||
"You exceeded your current quota, please check your plan and billing details.",
|
||||
url: "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:streamGenerateContent",
|
||||
requestBodyValues: {},
|
||||
statusCode: 429,
|
||||
responseBody: JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message:
|
||||
"You exceeded your current quota, please check your plan and billing details.",
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.QuotaFailure",
|
||||
violations: [
|
||||
{
|
||||
quotaMetric:
|
||||
"generativelanguage.googleapis.com/generate_content_free_tier_requests",
|
||||
quotaId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
retryDelay: "86400s",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
it("does not retry a per-day quota exhaustion despite the 429", () => {
|
||||
expect(
|
||||
isRetryableProviderError(
|
||||
googleQuotaError("GenerateRequestsPerDayPerProjectPerModel-FreeTier"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still retries a per-minute rate limit", () => {
|
||||
expect(
|
||||
isRetryableProviderError(
|
||||
googleQuotaError(
|
||||
"GenerateRequestsPerMinutePerProjectPerModel-FreeTier",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("not retryable", () => {
|
||||
it("does not retry a credential rejection (401)", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type ProviderErrorClass, safeJsonParse } from "@cline/shared";
|
||||
import { AISDKError, APICallError, RetryError, TypeValidationError } from "ai";
|
||||
import { isQuotaExhaustedError } from "./format";
|
||||
|
||||
export { isQuotaExhaustedError };
|
||||
|
||||
/**
|
||||
* Provider codes that unambiguously identify a context-window overflow
|
||||
@@ -423,6 +426,11 @@ function isRetryableFromSignals(value: unknown): boolean {
|
||||
* error or a flattened message string.
|
||||
*/
|
||||
export function isRetryableProviderError(error: unknown): boolean {
|
||||
// A daily quota exhaustion cannot succeed on retry, however the typed
|
||||
// 429 signal reads; per-minute limits keep the retryable path below.
|
||||
if (isQuotaExhaustedError(error)) {
|
||||
return false;
|
||||
}
|
||||
// Prefer the AI SDK's own typed retryability signal.
|
||||
try {
|
||||
const typed = isRetryableTypedError(error, 0);
|
||||
|
||||
@@ -106,6 +106,43 @@ describe("extractErrorMessage", () => {
|
||||
it("falls back to JSON instead of [object Object] for opaque objects", () => {
|
||||
expect(extractErrorMessage({ status: 502 })).toBe('{"status":502}');
|
||||
});
|
||||
|
||||
it("surfaces Google quota details behind the generic quota message", () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message:
|
||||
"You exceeded your current quota, please check your plan and billing details.",
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.QuotaFailure",
|
||||
violations: [
|
||||
{
|
||||
quotaMetric:
|
||||
"generativelanguage.googleapis.com/generate_content_free_tier_requests",
|
||||
quotaId: "GenerateRequestsPerDayPerProjectPerModel-FreeTier",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
retryDelay: "86400s",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
extractErrorMessage({
|
||||
message:
|
||||
"You exceeded your current quota, please check your plan and billing details.",
|
||||
statusCode: 429,
|
||||
responseBody: body,
|
||||
}),
|
||||
).toBe(
|
||||
"You exceeded your current quota, please check your plan and billing details. (Google quota exceeded: quota GenerateRequestsPerDayPerProjectPerModel-FreeTier, retry after 86400s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClineNotSubscribedError", () => {
|
||||
|
||||
@@ -1,5 +1,152 @@
|
||||
import { safeJsonParse, safeJsonStringify } from "@cline/shared";
|
||||
|
||||
/** Google API quota detail carried in a 429 `RESOURCE_EXHAUSTED` body. */
|
||||
export interface QuotaFailureDetail {
|
||||
quotaId?: string;
|
||||
quotaMetric?: string;
|
||||
retryDelay?: string;
|
||||
}
|
||||
|
||||
const QUOTA_DETAIL_KEYS = [
|
||||
"error",
|
||||
"data",
|
||||
"responseBody",
|
||||
"cause",
|
||||
"errors",
|
||||
"lastError",
|
||||
"value",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Find Google's structured quota detail (`google.rpc.QuotaFailure` /
|
||||
* `RetryInfo` in `error.details`) wherever an SDK error carries it: parsed
|
||||
* `data`, a raw `responseBody`, or nested under a `RetryError`'s attempts.
|
||||
* Returns an empty detail when the body is a quota rejection without
|
||||
* violations, and `undefined` when no quota failure is present.
|
||||
*/
|
||||
export function extractQuotaFailure(
|
||||
value: unknown,
|
||||
visited = new Set<unknown>(),
|
||||
depth = 0,
|
||||
): QuotaFailureDetail | undefined {
|
||||
if (value == null || depth > 8) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = safeJsonParse<unknown>(value);
|
||||
return parsed === undefined
|
||||
? undefined
|
||||
: extractQuotaFailure(parsed, visited, depth + 1);
|
||||
}
|
||||
if (typeof value !== "object" || visited.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
visited.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = extractQuotaFailure(item, visited, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const error =
|
||||
record.error && typeof record.error === "object"
|
||||
? (record.error as Record<string, unknown>)
|
||||
: undefined;
|
||||
const details = Array.isArray(error?.details)
|
||||
? error.details
|
||||
: Array.isArray(record.details)
|
||||
? record.details
|
||||
: undefined;
|
||||
if (details) {
|
||||
let detail: QuotaFailureDetail | undefined;
|
||||
for (const entry of details) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typed = entry as Record<string, unknown>;
|
||||
const type = typeof typed["@type"] === "string" ? typed["@type"] : "";
|
||||
if (type.includes("QuotaFailure")) {
|
||||
detail ??= {};
|
||||
const violation = Array.isArray(typed.violations)
|
||||
? (typed.violations[0] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
if (typeof violation?.quotaId === "string") {
|
||||
detail.quotaId = violation.quotaId;
|
||||
}
|
||||
if (typeof violation?.quotaMetric === "string") {
|
||||
detail.quotaMetric = violation.quotaMetric;
|
||||
}
|
||||
} else if (type.includes("RetryInfo")) {
|
||||
detail ??= {};
|
||||
if (typeof typed.retryDelay === "string") {
|
||||
detail.retryDelay = typed.retryDelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (detail) {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
if (error?.status === "RESOURCE_EXHAUSTED") {
|
||||
return {};
|
||||
}
|
||||
for (const key of QUOTA_DETAIL_KEYS) {
|
||||
const found = extractQuotaFailure(record[key], visited, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const PER_DAY_QUOTA_PATTERN = /per[\s_-]?day|daily/i;
|
||||
|
||||
/**
|
||||
* Whether an error is a quota exhaustion a same-day retry cannot fix, as
|
||||
* opposed to a transient per-minute rate limit. Google marks daily quotas
|
||||
* in the quota id/metric; a quota body without either is treated as
|
||||
* exhausted, as is Google's bare quota message once structure is stripped.
|
||||
*/
|
||||
export function isQuotaExhaustedError(error: unknown): boolean {
|
||||
const quota = extractQuotaFailure(error);
|
||||
if (quota) {
|
||||
const label = `${quota.quotaId ?? ""} ${quota.quotaMetric ?? ""}`.trim();
|
||||
return !label || PER_DAY_QUOTA_PATTERN.test(label);
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return /exceeded your current quota/i.test(error);
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
const record = error as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.message === "string" &&
|
||||
/exceeded your current quota/i.test(record.message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function quotaSuffix(error: unknown): string | undefined {
|
||||
const quota = extractQuotaFailure(error);
|
||||
if (!quota) {
|
||||
return undefined;
|
||||
}
|
||||
const label = quota.quotaId ?? quota.quotaMetric;
|
||||
const parts = [
|
||||
...(label ? [`quota ${label}`] : []),
|
||||
...(quota.retryDelay ? [`retry after ${quota.retryDelay}`] : []),
|
||||
];
|
||||
return parts.length
|
||||
? ` (Google quota exceeded: ${parts.join(", ")})`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: unknown): string {
|
||||
// Generic SDK wrappers carry no signal of their own — when present we prefer
|
||||
// the underlying cause/detail (e.g. AI SDK's AI_NoOutputGeneratedError).
|
||||
@@ -141,7 +288,10 @@ export function extractErrorMessage(error: unknown): string {
|
||||
|
||||
const structuredMessage = extractStructuredMessage(error);
|
||||
if (structuredMessage) {
|
||||
return structuredMessage;
|
||||
const suffix = quotaSuffix(error);
|
||||
return suffix && !structuredMessage.includes(suffix)
|
||||
? `${structuredMessage}${suffix}`
|
||||
: structuredMessage;
|
||||
}
|
||||
|
||||
// String() on a plain object yields "[object Object]" — JSON is at least
|
||||
|
||||
@@ -2823,7 +2823,7 @@ describe("sdk-gateway", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses three SDK stream retries without allowing another whole-turn retry", async () => {
|
||||
it("requests one gated SDK stream retry without allowing another whole-turn retry", async () => {
|
||||
const error = {
|
||||
message: "upstream timeout",
|
||||
code: "504",
|
||||
@@ -2846,7 +2846,7 @@ describe("sdk-gateway", () => {
|
||||
}),
|
||||
);
|
||||
expect(streamTextSpy.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
streamRetries: 3,
|
||||
streamRetries: 0,
|
||||
});
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
reason: "error",
|
||||
|
||||
@@ -159,6 +159,76 @@ describe("Gemini thinking wire contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini quota wire contract", () => {
|
||||
it("sends a per-day quota 429 once and surfaces its quota detail", async () => {
|
||||
let calls = 0;
|
||||
const fetchStub = (async () => {
|
||||
calls++;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message:
|
||||
"You exceeded your current quota, please check your plan and billing details.",
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.QuotaFailure",
|
||||
violations: [
|
||||
{
|
||||
quotaMetric:
|
||||
"generativelanguage.googleapis.com/generate_content_free_tier_requests",
|
||||
quotaId:
|
||||
"GenerateRequestsPerDayPerProjectPerModel-FreeTier",
|
||||
quotaValue: "20",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
retryDelay: "86400s",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 429, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{ providerId: "gemini", apiKey: "test-key", fetch: fetchStub },
|
||||
],
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
for await (const event of await gateway.stream({
|
||||
providerId: "gemini",
|
||||
modelId: "gemini-3.5-flash-lite",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Reply exactly OK." }],
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(calls).toBe(1);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: "error",
|
||||
errorRetryable: false,
|
||||
error: expect.stringContaining(
|
||||
"GenerateRequestsPerDayPerProjectPerModel-FreeTier",
|
||||
),
|
||||
});
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
error: expect.stringContaining("retry after 86400s"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini output limit wire contract", () => {
|
||||
it.each([
|
||||
32_768, 65_536,
|
||||
|
||||
Reference in New Issue
Block a user