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 <noreply@anthropic.com>
This commit is contained in:
Jadugar 2026-08-03 16:15:45 +05:30 • committed by GitHub
parent ebf33c0c22
commit b9d360a2c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 78 additions and 3 deletions

View file

@ -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;

View file

@ -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<T>(
request: () => Promise<T>,
options?: Pick<StreamOptions, "maxRetries" | "maxRetryDelayMs" | "signal">,
): Promise<T> {
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,
},
);
}

View file

@ -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;

View file

@ -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<string>>().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<string>>().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<string>>().mockRejectedValue(error);
await expect(retryGoogleRequest(request, { maxRetries: 2 })).rejects.toBe(error);
expect(request).toHaveBeenCalledTimes(1);
});
});