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:
Saoud Rizwan
2026-10-19 03:41:54 +00:00
parent 731f48462b
commit e8d6b9ea2c
8 changed files with 409 additions and 12 deletions
+3 -2
View File
@@ -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.
+85 -7
View File
@@ -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", () => {
+151 -1
View File
@@ -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,