fix(coding-agent): respected google retry hints

Parsed provider retry hint text before falling back to quota cooldowns so Google per-minute token limits use the server-provided delay.

Fixes #1253
This commit is contained in:
roboomp
2026-05-21 09:38:43 +00:00
parent 8dc9125b0b
commit 1b6ef3b5dc
2 changed files with 69 additions and 0 deletions
@@ -76,6 +76,7 @@ import {
} from "@oh-my-pi/pi-ai";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import {
extractRetryHint,
getAgentDbPath,
isEnoent,
isUnexpectedSocketCloseMessage,
@@ -6949,6 +6950,11 @@ export class AgentSession {
}
}
const retryHintMs = extractRetryHint(undefined, errorMessage);
if (retryHintMs !== undefined) {
return retryHintMs;
}
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
if (resetMsMatch) {
const resetMs = Number(resetMsMatch[1]);
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, Effort, getBundledModel, type Model, writeModelCache } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
@@ -73,6 +74,7 @@ describe("AgentSession retry fallback", () => {
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key");
authStorage.setRuntimeApiKey("openai", "openai-test-key");
authStorage.setRuntimeApiKey("google", "google-test-key");
modelRegistry = new ModelRegistry(authStorage);
});
@@ -194,6 +196,67 @@ describe("AgentSession retry fallback", () => {
]);
});
it("uses Google retry hints in quota errors before quota backoff", async () => {
const model = getBundledModel("google", "gemini-1.5-flash");
if (!model) {
throw new Error("Expected bundled Google test model to exist");
}
const errorMessage =
"Google API error (429): Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 250000. Please retry in 0.05s.";
const requestedModels: string[] = [];
const mock = createMockModel({
responses: [{ throw: errorMessage }, { content: ["Recovered after Google quota retry"] }],
});
const agent = new Agent({
getApiKey: provider => `${provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: (requestedModel, context, options) => {
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
return mock.stream(requestedModel, context, options);
},
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxRetries": 1,
});
settings.setModelRole("default", `${model.provider}/${model.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
const waitSpy = vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
await session.prompt("Retry Google token quota");
await session.waitForIdle();
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
expect(retryStartEvents).toHaveLength(1);
expect(retryStartEvents[0]).toMatchObject({
attempt: 1,
maxAttempts: 1,
delayMs: 50,
errorMessage,
});
expect(waitSpy).toHaveBeenCalledWith(50, { signal: expect.any(AbortSignal) });
expect(retryEndEvents).toHaveLength(1);
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
const lastAssistant = getLastAssistantMessage(session);
expect(lastAssistant.stopReason).toBe("stop");
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after Google quota retry" });
});
it("auto-retries preserved OpenAI first-event timeout errors", async () => {
const model = getBundledModel("openai", "gpt-4o-mini");
if (!model) {