Skip to content

@ai-sdk/google: 429 error schema drops google.rpc.RetryInfo, so Gemini's retryDelay is unreachable from APICallError.data #18627

Description

@mohankumar0311

Description

Summary: @ai-sdk/google's error schema discards error.details, which is the only place Gemini reports its retry delay. Callers who schedule their own retries therefore cannot read the provider's suggested delay off the typed APICallError, and the SDK's own retry layer has nothing to read either.

Background

Gemini returns 429 without a Retry-After header. It reports the delay in the JSON body as a google.rpc.RetryInfo entry under error.details:

{
  "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": [...] },
      { "@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "34.4s" }
    ]
  }
}

The problem

googleErrorDataSchema is a plain (non-loose) Zod object covering only code, message, and status:

const googleErrorDataSchema = lazySchema(() =>
  zodSchema(
    z.object({
      error: z.object({
        code: z.number().nullable(),
        message: z.string(),
        status: z.string(),
      }),
    }),
  ),
);

Zod strips unknown keys, so details is dropped before createJsonErrorResponseHandler attaches the parsed value as APICallError.data. Two consequences:

  1. The retry-header support added in SDK does not respect rate limit headers from API providers #7247 reads retry-after / retry-after-ms. Gemini sends neither, so maxRetries always falls back to exponential backoff even when the API said exactly how long to wait.
  2. Callers who do their own scheduling — durable workflow engines, job queues, anything that must hand a delay to an external scheduler rather than block a process — cannot obtain the value from the typed error. This is the same gap raised in Respect retry-after header for API (Anthropic at least) #5018 for headers ("the error types themselves don't expose the header value to callers implementing custom retry logic"), but for Google it is worse: there is no header to re-parse in the first place.

Current behaviour

APICallError.data never contains details, regardless of what the API returned:

isAPICallError: true
statusCode: 429
isRetryable: true
retry-after header: undefined
error.data: {"error":{"code":429,"message":"...","status":"RESOURCE_EXHAUSTED"}}

Workaround (works today, but shouldn't be necessary)

APICallError.responseBody retains the full raw body, so the delay is recoverable by re-parsing a payload the SDK already parsed:

if (APICallError.isInstance(error) && error.statusCode === 429) {
  const parsed = JSON.parse(error.responseBody ?? "{}");
  const info = parsed.error?.details?.find((d: { "@type"?: string }) =>
    d["@type"]?.endsWith("google.rpc.RetryInfo"),
  );
  const retryDelay = info?.retryDelay; // "34.4s"
}

That means going around the typed data field and hand-parsing an untyped body, which is easy to get subtly wrong.

Expected behaviour

Preserve error.details so it reaches APICallError.data. Minimal, backward-compatible change:

z.object({
  error: z.object({
    code: z.number().nullable(),
    message: z.string(),
    status: z.string(),
    details: z.array(z.unknown()).nullish(),
  }),
})

Going further (optional, and a larger change): @ai-sdk/google could surface the RetryInfo delay to the retry layer the way retry-after is surfaced for other providers, so maxRetries honours it automatically. Google's own Python SDK has the same request open — googleapis/python-genai#1875.

Preserving details alone would already unblock every caller doing custom scheduling, without waiting on the retry-API design discussion in #4842.

Related

Reproduction

Runnable with a stub fetch, no API key needed:

import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { APICallError } from "@ai-sdk/provider";
import { generateText } from "ai";

const body = JSON.stringify({
  error: {
    code: 429,
    message: "You exceeded your current quota, please check your plan.",
    status: "RESOURCE_EXHAUSTED",
    details: [
      {
        "@type": "type.googleapis.com/google.rpc.QuotaFailure",
        violations: [{ quotaId: "GenerateRequestsPerMinutePerProjectPerModel-FreeTier" }],
      },
      { "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "34.4s" },
    ],
  },
});

const google = createGoogleGenerativeAI({
  apiKey: "x",
  fetch: async () =>
    new Response(body, { status: 429, headers: { "content-type": "application/json" } }),
});

try {
  await generateText({ model: google("gemini-2.5-flash"), prompt: "hi", maxRetries: 0 });
} catch (error) {
  if (!APICallError.isInstance(error)) throw error;
  console.log("retry-after header:", error.responseHeaders?.["retry-after"]); // undefined
  console.log("error.data:", JSON.stringify(error.data));                     // no `details`
  console.log("responseBody has RetryInfo:", error.responseBody?.includes("RetryInfo")); // true
}

Output:

retry-after header: undefined
error.data: {"error":{"code":429,"message":"You exceeded your current quota, please check your plan.","status":"RESOURCE_EXHAUSTED"}}
responseBody has RetryInfo: true

AI SDK Version

  • ai: 5.0.118
  • @ai-sdk/google: 2.0.70
  • @ai-sdk/provider: 2.0.1
  • @ai-sdk/provider-utils: 3.0.23

packages/google/src/google-error.ts on main is unchanged, so current versions are affected too.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions