From b9d360a2c753e058a90dc0a252950631501c10d0 Mon Sep 17 00:00:00 2001 From: Jadugar <112567110+vish-pr@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:15:45 +0530 Subject: [PATCH] fix(ai): retry transient provider errors in Google adapters (#7471) The google-vertex and google-generative-ai adapters called client.models.generateContentStream bare, so a transient 429/5xx before the first token became a terminal errored assistant message instead of being retried like in the Anthropic, OpenAI, Azure, and OpenRouter adapters. Wrap the initial request in the shared retryProviderRequest via a new retryGoogleRequest helper that also normalizes @google/genai's ApiError, which has a status property but no headers property, so retryProviderRequest's provider-error guard recognizes it. Opt-in via the existing maxRetries stream option; default behavior is unchanged. Co-authored-by: Claude Fable 5 --- packages/ai/src/api/google-generative-ai.ts | 3 +- packages/ai/src/api/google-shared.ts | 35 ++++++++++++++++- packages/ai/src/api/google-vertex.ts | 3 +- packages/ai/test/google-shared-retry.test.ts | 40 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/google-shared-retry.test.ts diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 2e2b1784a..8bd631900 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -32,6 +32,7 @@ import { mapStopReason, resolveGoogleFunctionCallingMode, retainThoughtSignature, + retryGoogleRequest, supportsGoogleStrictToolSampling, } from "./google-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -88,7 +89,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( if (nextParams !== undefined) { params = nextParams as GenerateContentParameters; } - const googleStream = await client.models.generateContentStream(params); + const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options); stream.push({ type: "start", partial: output }); let currentBlock: TextContent | ThinkingContent | null = null; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index c4abdc86e..5a1d4729e 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -3,7 +3,8 @@ */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts"; +import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts"; +import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -374,3 +375,35 @@ export function mapStopReasonString(reason: string): StopReason { return "error"; } } + +/** + * Run a Google GenAI SDK request with the shared provider retry policy + * (408/409/429/5xx with backoff, honoring retry-after), mirroring how the + * Anthropic and OpenAI adapters wrap their initial request in + * retryProviderRequest. The SDK's ApiError has a `status` property but no + * `headers` property, and retryProviderRequest only retries errors that carry + * both, so normalize the error by adding the missing `headers` before + * rethrowing. + */ +export function retryGoogleRequest( + request: () => Promise, + options?: Pick, +): Promise { + return retryProviderRequest( + async () => { + try { + return await request(); + } catch (error) { + if (error instanceof Error && "status" in error && !("headers" in error)) { + (error as { headers?: Headers }).headers = undefined; + } + throw error; + } + }, + { + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + signal: options?.signal, + }, + ); +} diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 7baa872d1..112c385f3 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -37,6 +37,7 @@ import { mapStopReason, resolveGoogleFunctionCallingMode, retainThoughtSignature, + retryGoogleRequest, supportsGoogleStrictToolSampling, } from "./google-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -106,7 +107,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( if (nextParams !== undefined) { params = nextParams as GenerateContentParameters; } - const googleStream = await client.models.generateContentStream(params); + const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options); stream.push({ type: "start", partial: output }); let currentBlock: TextContent | ThinkingContent | null = null; diff --git a/packages/ai/test/google-shared-retry.test.ts b/packages/ai/test/google-shared-retry.test.ts new file mode 100644 index 000000000..77245280e --- /dev/null +++ b/packages/ai/test/google-shared-retry.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { retryGoogleRequest } from "../src/api/google-shared.ts"; + +/** Shaped like @google/genai's ApiError: has `status`, but no `headers`. */ +function googleApiError(status: number): Error { + return Object.assign(new Error(`got status: ${status}`), { status }); +} + +describe("google request retries", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries a headers-less SDK error with a retryable status", async () => { + vi.useFakeTimers(); + const request = vi.fn<() => Promise>().mockRejectedValueOnce(googleApiError(429)).mockResolvedValue("ok"); + + const result = retryGoogleRequest(request, { maxRetries: 1 }); + await vi.advanceTimersByTimeAsync(500); + + await expect(result).resolves.toBe("ok"); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("does not retry when maxRetries is unset", async () => { + const error = googleApiError(429); + const request = vi.fn<() => Promise>().mockRejectedValue(error); + + await expect(retryGoogleRequest(request)).rejects.toBe(error); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("does not retry a non-retryable status", async () => { + const error = googleApiError(400); + const request = vi.fn<() => Promise>().mockRejectedValue(error); + + await expect(retryGoogleRequest(request, { maxRetries: 2 })).rejects.toBe(error); + expect(request).toHaveBeenCalledTimes(1); + }); +});