fix: retry transient Gemini failures
This commit is contained in:
parent
1ac3d49562
commit
59e40e9047
|
|
@ -574,4 +574,42 @@ describe("GeminiAdapter.chat()", () => {
|
|||
expect(callArgs.tools).toBeDefined();
|
||||
expect(callArgs.tools[0].functionDeclarations).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("retries transient provider failures before returning content", async () => {
|
||||
const providerError = Object.assign(new Error("Service Unavailable"), {
|
||||
status: 503,
|
||||
});
|
||||
mockGenerateContent
|
||||
.mockRejectedValueOnce(providerError)
|
||||
.mockRejectedValueOnce(providerError)
|
||||
.mockResolvedValueOnce(makeGeminiResponse({ text: "Recovered" }));
|
||||
|
||||
const adapter = new GeminiAdapter(TEST_CONFIG);
|
||||
const result = await adapter.chat({
|
||||
messages: [makeChatMessage("user", "Hi")],
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
});
|
||||
|
||||
expect(result.content).toBe("Recovered");
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("does not retry non-transient provider failures", async () => {
|
||||
const providerError = Object.assign(new Error("Bad request"), {
|
||||
status: 400,
|
||||
});
|
||||
mockGenerateContent.mockRejectedValueOnce(providerError);
|
||||
|
||||
const adapter = new GeminiAdapter(TEST_CONFIG);
|
||||
await expect(
|
||||
adapter.chat({
|
||||
messages: [makeChatMessage("user", "Hi")],
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
}),
|
||||
).rejects.toThrow("Bad request");
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,51 @@ const TYPE_MAP: Record<string, SchemaType> = {
|
|||
object: SchemaType.OBJECT,
|
||||
};
|
||||
|
||||
const GEMINI_MAX_ATTEMPTS = 5;
|
||||
const GEMINI_RETRY_BASE_DELAY_MS = process.env.NODE_ENV === "test" ? 0 : 1000;
|
||||
const GEMINI_RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
||||
|
||||
function getProviderStatus(error: unknown): number | null {
|
||||
const status = (error as { status?: unknown })?.status;
|
||||
if (typeof status === "number") return status;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const match = message.match(/\[(\d{3})\s/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function isRetryableProviderError(error: unknown): boolean {
|
||||
const status = getProviderStatus(error);
|
||||
return status !== null && GEMINI_RETRYABLE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function withGeminiRetry<T>(operation: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= GEMINI_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === GEMINI_MAX_ATTEMPTS || !isRetryableProviderError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const backoffMs = GEMINI_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||
const jitterMs =
|
||||
GEMINI_RETRY_BASE_DELAY_MS === 0 ? 0 : Math.random() * 250;
|
||||
await wait(backoffMs + jitterMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts internal ChatMessages into Gemini Content[].
|
||||
*
|
||||
|
|
@ -291,7 +336,9 @@ export class GeminiAdapter implements ProviderAdapter {
|
|||
request.tools = [{ functionDeclarations }];
|
||||
}
|
||||
|
||||
const result = await this.model.generateContent(request);
|
||||
const result = await withGeminiRetry(() =>
|
||||
this.model.generateContent(request),
|
||||
);
|
||||
return fromGeminiResponse(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue